explor
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM14.79 12.53L11.26 16.06C11.11 16.21 10.92 16.28 10.73 16.28C10.54 16.28 10.35 16.21 10.2 16.06C9.91 15.77 9.91 15.29 10.2 15L13.2 12L10.2 9C9.91 8.71 9.91 8.23 10.2 7.94C10.49 7.65 10.97 7.65 11.26 7.94L14.79 11.47C15.09 11.76 15.09 12.24 14.79 12.53Z" fill="#292D32"/>
|
||||
<path d="M8.9101 20.67C8.7201 20.67 8.5301 20.6 8.3801 20.45C8.0901 20.16 8.0901 19.68 8.3801 19.39L14.9001 12.87C15.3801 12.39 15.3801 11.61 14.9001 11.13L8.3801 4.60999C8.0901 4.31999 8.0901 3.83999 8.3801 3.54999C8.6701 3.25999 9.1501 3.25999 9.4401 3.54999L15.9601 10.07C16.4701 10.58 16.7601 11.27 16.7601 12C16.7601 12.73 16.4801 13.42 15.9601 13.93L9.4401 20.45C9.2901 20.59 9.1001 20.67 8.9101 20.67Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 530 B |
4
public/images/icons/chat/reply.svg
Normal file
4
public/images/icons/chat/reply.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.1299 19.06H7.12988C6.71988 19.06 6.37988 18.72 6.37988 18.31C6.37988 17.9 6.71988 17.56 7.12988 17.56H15.1299C17.4699 17.56 19.3799 15.65 19.3799 13.31C19.3799 10.97 17.4699 9.06 15.1299 9.06H4.12988C3.71988 9.06 3.37988 8.72 3.37988 8.31C3.37988 7.9 3.71988 7.56 4.12988 7.56H15.1299C18.2999 7.56 20.8799 10.14 20.8799 13.31C20.8799 16.48 18.2999 19.06 15.1299 19.06Z" fill="#292D32"/>
|
||||
<path d="M6.43006 11.56C6.24006 11.56 6.05006 11.49 5.90006 11.34L3.34006 8.78C3.05006 8.49 3.05006 8.01 3.34006 7.72L5.90006 5.16C6.19006 4.87 6.67006 4.87 6.96006 5.16C7.25006 5.45 7.25006 5.93 6.96006 6.22L4.93006 8.25001L6.96006 10.28C7.25006 10.57 7.25006 11.05 6.96006 11.34C6.82006 11.49 6.62006 11.56 6.43006 11.56Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 845 B |
@@ -18,6 +18,7 @@ export async function fetchPosts(
|
||||
lng?: string;
|
||||
feedMode?: "grid" | "reels";
|
||||
seedPostId?: string;
|
||||
sort?: "latest";
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 || "خطا در ویرایش استوری");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string[]>([""]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
|
||||
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() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Media area */}
|
||||
{/* Story editor (media + draggable text) */}
|
||||
{isStory && preview && file ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<StoryEditor
|
||||
mediaUrl={preview}
|
||||
mediaType={file.type.startsWith("video/") ? "video" : "image"}
|
||||
overlays={storyOverlays}
|
||||
onChange={setStoryOverlays}
|
||||
/>
|
||||
<label className="mx-auto rounded-lg bg-neutral-100 px-4 py-2 text-center text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
تغییر فایل
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
className="hidden"
|
||||
onChange={onPickMain}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
/* Media area */
|
||||
<div
|
||||
className={`relative flex flex-1 flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-950 ${
|
||||
isStory ? "aspect-[9/16] max-h-[55vh]" : "min-h-[280px]"
|
||||
@@ -293,6 +328,7 @@ export default function NewPostPage() {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra images for photo post */}
|
||||
{mode === "image" && !isStory && (
|
||||
|
||||
@@ -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 && (
|
||||
<ForwardMessageModal
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
|
||||
interface ChatActionBarProps {
|
||||
onReply: () => 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]"
|
||||
>
|
||||
<BoldIcon name="direct-right" size={18} tinted className="text-white" />
|
||||
<ChatBoldIcon name="reply" size={18} className="text-white" />
|
||||
پاسخ
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -17,13 +17,15 @@ export type ChatBoldIconName =
|
||||
| "send"
|
||||
| "search"
|
||||
| "delete"
|
||||
| "back";
|
||||
| "back"
|
||||
| "reply";
|
||||
|
||||
/** Chat-specific SVG assets (custom uploads) */
|
||||
const CHAT_ICON_SRC: Partial<Record<ChatBoldIconName, string>> = {
|
||||
timer: staticIconUrl("/images/icons/chat/timer.svg"),
|
||||
search: staticIconUrl("/images/icons/chat/search-normal.svg"),
|
||||
back: staticIconUrl("/images/icons/chat/back.svg"),
|
||||
reply: staticIconUrl("/images/icons/chat/reply.svg"),
|
||||
};
|
||||
|
||||
const ICON_NAME: Record<ChatBoldIconName, string> = {
|
||||
@@ -40,6 +42,7 @@ const ICON_NAME: Record<ChatBoldIconName, string> = {
|
||||
search: "search-normal",
|
||||
delete: "trash",
|
||||
back: "arrow-circle-right",
|
||||
reply: "direct-right",
|
||||
};
|
||||
|
||||
interface ChatBoldIconProps {
|
||||
|
||||
@@ -174,7 +174,8 @@ const ChatMessageCard = ({
|
||||
|
||||
const { dragProps, replyOpacity, replyScale } = useSwipeToReply(
|
||||
handleSwipeReply,
|
||||
swipeEnabled
|
||||
swipeEnabled,
|
||||
isSender ? "left" : "right"
|
||||
);
|
||||
|
||||
const swipeDragStart =
|
||||
@@ -404,11 +405,15 @@ const ChatMessageCard = ({
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 flex w-11 items-center justify-center text-[#0095f6]",
|
||||
isSender ? "right-0" : "right-0"
|
||||
isSender ? "right-0" : "left-0"
|
||||
)}
|
||||
style={{ opacity: replyOpacity, scale: replyScale }}
|
||||
>
|
||||
<BoldIcon name="direct-right" size={20} tinted className="text-current" />
|
||||
<ChatBoldIcon
|
||||
name="reply"
|
||||
size={20}
|
||||
className={cn("text-current", !isSender && "-scale-x-100")}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -629,20 +634,39 @@ const ChatMessageCard = ({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-xl"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(null)}
|
||||
className="gentle-transition absolute right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[2] flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur-md active:scale-90"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
|
||||
</button>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
initial={{ scale: 0.92, opacity: 0.6 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.92, opacity: 0 }}
|
||||
transition={{ duration: 0.22, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="max-h-[92vh] max-w-[95vw] p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{fileType === "video" ? (
|
||||
<video src={preview} controls autoPlay className="max-h-[88vh] rounded-lg" />
|
||||
<video
|
||||
src={preview}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-[88vh] rounded-2xl shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt="" className="max-h-[88vh] object-contain" />
|
||||
<img
|
||||
src={preview}
|
||||
alt=""
|
||||
className="max-h-[88vh] rounded-2xl object-contain shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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<typeof setTimeout> | 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]);
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -173,9 +175,33 @@ const MessageInput = ({
|
||||
}`}
|
||||
>
|
||||
{(replyLabel ||
|
||||
showMediaOptions ||
|
||||
(selfDestructSeconds != null && isChatThread) ||
|
||||
(viewOnceMedia && isChatThread)) && (
|
||||
<div className="pointer-events-auto relative z-[2] mb-2 flex w-full max-w-lg flex-col gap-2">
|
||||
{showMediaOptions && (
|
||||
<div className="glass-panel flex items-center justify-between gap-2 rounded-2xl px-3 py-2 shadow-lg">
|
||||
<span className="text-[11px] font-medium text-neutral-500 dark:text-neutral-400">
|
||||
گزینههای ارسال مدیا
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onSelfDestructChange && (
|
||||
<TimedMessagePicker
|
||||
value={selfDestructSeconds}
|
||||
onChange={onSelfDestructChange}
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
)}
|
||||
{onViewOnceChange && (
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
onChange={onViewOnceChange}
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{replyLabel && (
|
||||
<div className="glass-panel flex items-center gap-2 rounded-2xl px-3 py-2.5 text-xs shadow-lg">
|
||||
<span className="flex-1 truncate text-neutral-700 dark:text-neutral-200">
|
||||
@@ -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"
|
||||
>
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
{isChatThread && onSelfDestructChange && (
|
||||
<TimedMessagePicker
|
||||
value={selfDestructSeconds}
|
||||
onChange={onSelfDestructChange}
|
||||
disabled={blocked_you || isRecording}
|
||||
/>
|
||||
)}
|
||||
{isChatThread && onViewOnceChange && (
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
onChange={onViewOnceChange}
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import Modal from "../elements/Modal";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import ViewOnceToggle from "./ViewOnceToggle";
|
||||
import TimedMessagePicker from "./TimedMessagePicker";
|
||||
import { formatTimedLabel } from "@/lib/chat/timedMessages";
|
||||
|
||||
interface MultiImageModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,6 +18,8 @@ interface MultiImageModalProps {
|
||||
onConfirm: () => void;
|
||||
viewOnceMedia?: boolean;
|
||||
onViewOnceChange?: (value: boolean) => void;
|
||||
selfDestructSeconds?: number | null;
|
||||
onSelfDestructChange?: (seconds: number | null) => void;
|
||||
}
|
||||
|
||||
export default function MultiImageModal({
|
||||
@@ -24,65 +30,116 @@ export default function MultiImageModal({
|
||||
onConfirm,
|
||||
viewOnceMedia = false,
|
||||
onViewOnceChange,
|
||||
selfDestructSeconds = null,
|
||||
onSelfDestructChange,
|
||||
}: MultiImageModalProps) {
|
||||
if (!isOpen || files.length === 0) return null;
|
||||
|
||||
const single = files.length === 1;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onCancel} height="min(85vh, 640px)">
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<p className="mb-3 text-center text-sm font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{files.length} عکس انتخاب شده
|
||||
</p>
|
||||
<div className="grid flex-1 grid-cols-3 gap-2 overflow-y-auto">
|
||||
<Modal isOpen={isOpen} onClose={onCancel} height="min(88vh, 680px)">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pb-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full bg-black/5 text-lg leading-none text-neutral-600 active:scale-90 dark:bg-white/10 dark:text-neutral-300"
|
||||
aria-label="بستن"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{single ? "پیشنمایش عکس" : `${files.length} عکس انتخاب شده`}
|
||||
</span>
|
||||
<span className="h-9 w-9" aria-hidden />
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div
|
||||
className={
|
||||
single
|
||||
? "flex flex-1 items-center justify-center overflow-hidden px-4"
|
||||
: "grid flex-1 auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto px-4 pb-2 sm:grid-cols-3"
|
||||
}
|
||||
>
|
||||
{files.map((file, i) => (
|
||||
<motion.div
|
||||
key={`${file.name}-${i}`}
|
||||
layout
|
||||
className="relative aspect-square overflow-hidden rounded-xl"
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl bg-neutral-100 shadow-sm ring-1 ring-black/5 dark:bg-neutral-800 dark:ring-white/10",
|
||||
single ? "max-h-[52vh] w-full" : "aspect-square"
|
||||
)}
|
||||
style={single ? { aspectRatio: "auto" } : undefined}
|
||||
>
|
||||
<Image
|
||||
src={URL.createObjectURL(file)}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
width={single ? 900 : 400}
|
||||
height={single ? 900 : 400}
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
single ? "max-h-[52vh] w-auto object-contain" : "object-cover"
|
||||
)}
|
||||
unoptimized
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(i)}
|
||||
className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-xs text-white"
|
||||
className="gentle-transition absolute left-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md active:scale-90"
|
||||
aria-label="حذف عکس"
|
||||
>
|
||||
×
|
||||
<BoldIcon name="close-circle" size={16} tinted className="text-white" />
|
||||
</button>
|
||||
{!single && (
|
||||
<span className="absolute bottom-2 right-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-black/45 px-1.5 text-[10px] font-semibold text-white backdrop-blur-md">
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col items-center gap-3">
|
||||
{onViewOnceChange && (
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
onChange={onViewOnceChange}
|
||||
/>
|
||||
<span>ارسال بهصورت یکبار مصرف</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center gap-4">
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col items-center gap-3 border-t border-black/5 px-4 pb-4 pt-3 dark:border-white/10">
|
||||
<div className="flex w-full max-w-xs items-center justify-center gap-5">
|
||||
{onSelfDestructChange && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TimedMessagePicker
|
||||
value={selfDestructSeconds}
|
||||
onChange={onSelfDestructChange}
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
|
||||
{selfDestructSeconds != null
|
||||
? formatTimedLabel(selfDestructSeconds)
|
||||
: "زماندار"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{onViewOnceChange && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
onChange={onViewOnceChange}
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
|
||||
یکبار مصرف
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="ig-dm-send-btn gentle-transition rounded-full px-8 py-2.5 text-sm font-semibold text-white active:scale-95"
|
||||
className="ig-dm-send-btn gentle-transition flex w-full max-w-xs items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
ارسال همه
|
||||
<ChatBoldIcon name="send" size={18} className="text-white -mr-0.5" />
|
||||
{single ? "ارسال" : "ارسال همه"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="gentle-transition rounded-full border border-neutral-300 px-6 py-2.5 text-sm active:scale-95 dark:border-neutral-600"
|
||||
>
|
||||
لغو
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function ExploreAcademyCard({
|
||||
className="gentle-transition block active:scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-lg bg-neutral-900 ${aspect}`}
|
||||
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<video
|
||||
@@ -110,20 +110,20 @@ export default function ExploreAcademyCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
|
||||
<ExploreAuthor
|
||||
userId={item.user_id || ""}
|
||||
userName={item.user_name}
|
||||
displayName={displayName}
|
||||
profileImage={item.profile_image}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
|
||||
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
|
||||
{item.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
|
||||
<ExploreAuthor
|
||||
userId={item.user_id || ""}
|
||||
userName={item.user_name}
|
||||
displayName={displayName}
|
||||
profileImage={item.profile_image}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
|
||||
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
|
||||
{item.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
const content = (
|
||||
<>
|
||||
<ProfileAvatar
|
||||
src={avatarSrc}
|
||||
alt={userName || displayName}
|
||||
@@ -43,6 +44,22 @@ export default function ExploreAuthor({
|
||||
<span className="truncate text-[10px] font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{userName ? `@${userName}` : displayName}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (userName) {
|
||||
return (
|
||||
<Link
|
||||
href={`/users/${userName}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="gentle-transition flex min-w-0 items-center gap-1.5 active:opacity-70"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">{content}</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ function ExploreCard({
|
||||
className="gentle-transition block active:scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-lg bg-neutral-900 ${aspect}`}
|
||||
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<video
|
||||
@@ -140,7 +140,7 @@ function ExploreCard({
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
@@ -150,20 +150,20 @@ function ExploreCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
|
||||
<ExploreAuthor
|
||||
userId={post.user_id}
|
||||
userName={post.user_name}
|
||||
displayName={displayName}
|
||||
profileImage={post.profile_image}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
|
||||
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
|
||||
{post.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
|
||||
<ExploreAuthor
|
||||
userId={post.user_id}
|
||||
userName={post.user_name}
|
||||
displayName={displayName}
|
||||
profileImage={post.profile_image}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
|
||||
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
|
||||
{post.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -334,7 +334,7 @@ export default function ExploreGrid({
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="columns-3 gap-2">
|
||||
<div className="columns-2 gap-2">
|
||||
{items.map((item) =>
|
||||
item.kind === "post" ? (
|
||||
<ExploreCard
|
||||
|
||||
@@ -25,6 +25,9 @@ import PageLoader from "@/components/ui/PageLoader";
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
|
||||
|
||||
// چند آیتم مانده به انتها، صفحهٔ بعد در پسزمینه پیشبارگذاری شود
|
||||
const PREFETCH_AHEAD = 5;
|
||||
|
||||
export type ExploreReelItem =
|
||||
| { kind: "post"; key: string; post: Post }
|
||||
| { kind: "academy"; key: string; academy: AcademyExploreItem };
|
||||
@@ -216,9 +219,9 @@ export default function ExploreReelsView({
|
||||
setActiveKey(item.key);
|
||||
}
|
||||
|
||||
// پیشبارگذاری صفحهٔ بعد چند آیتم زودتر تا از صفحهٔ لودینگ جلوگیری شود
|
||||
if (
|
||||
el.scrollTop + el.clientHeight >=
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,7 +16,7 @@ function Header() {
|
||||
const { state, startSync, endSync, isOffline, isSyncing } = useNetworkStatus();
|
||||
const [unreadMessages, setUnreadMessages] = useState<number>();
|
||||
const [unreadNotification, setUnreadNotification] = useState<number>();
|
||||
const compact = useScrollDirection(20);
|
||||
const { compact, hidden } = useScrollDirection(20);
|
||||
const token = Cookies.get("token");
|
||||
|
||||
const refreshBadges = async () => {
|
||||
@@ -120,8 +120,11 @@ function Header() {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"gentle-transition sticky top-0 z-[999] pt-[env(safe-area-inset-top)]",
|
||||
compact ? "py-2" : "py-3"
|
||||
"sticky top-0 z-[999] pt-[env(safe-area-inset-top)] transition-all duration-300 ease-in-out will-change-transform",
|
||||
compact ? "py-2" : "py-3",
|
||||
hidden
|
||||
? "pointer-events-none -translate-y-[130%] opacity-0"
|
||||
: "translate-y-0 opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className={headerBarClass}>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function InfinitePosts({
|
||||
fetchPosts(
|
||||
pageParam as number,
|
||||
4,
|
||||
{ ...filters, feedMode: "grid" },
|
||||
{ ...filters, feedMode: "grid", sort: "latest" },
|
||||
token
|
||||
),
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
|
||||
@@ -102,7 +102,8 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
<div>
|
||||
<div className="flex items-center justify-between relative px-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{expertiseList.map((item) => (
|
||||
{/* موقتاً مخفی شد — دکمههای فیلتر مدل/آرایشگر/عکاس (حذف نشود) */}
|
||||
{/* {expertiseList.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
@@ -115,7 +116,7 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
))} */}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 self-center">
|
||||
<button
|
||||
|
||||
@@ -16,6 +16,9 @@ import { normalizeUserLevel } from "@/lib/userLevel";
|
||||
import { readReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
import { reorderColdStartFeed } from "@/lib/coldStartFeed";
|
||||
|
||||
// چند پست مانده به انتها، صفحهٔ بعد در پسزمینه پیشبارگذاری شود
|
||||
const PREFETCH_AHEAD = 5;
|
||||
|
||||
interface PostFeedViewProps {
|
||||
initialPostId: string;
|
||||
initialPost?: Post | null;
|
||||
@@ -233,8 +236,9 @@ export default function PostFeedView({
|
||||
setActivePostId(post._id);
|
||||
}
|
||||
|
||||
// پیشبارگذاری صفحهٔ بعد چند پست زودتر تا از صفحهٔ لودینگ جلوگیری شود
|
||||
if (
|
||||
el.scrollTop + el.clientHeight >= el.scrollHeight - window.innerHeight * 0.5 &&
|
||||
index >= posts.length - PREFETCH_AHEAD &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
@@ -249,6 +253,24 @@ export default function PostFeedView({
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// بافر پسزمینه: وقتی کاربر به انتهای پستهای لودشده نزدیک میشود،
|
||||
// صفحهٔ بعدی را از قبل میگیریم (مثل پیشبارگذاری ریلز اینستاگرام)
|
||||
const activeIndex = useMemo(
|
||||
() => posts.findIndex((p) => String(p._id) === String(activePostId)),
|
||||
[posts, activePostId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
if (
|
||||
activeIndex >= posts.length - PREFETCH_AHEAD &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [activeIndex, posts.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (feedMode === "user" && !resolvedUserId && isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
|
||||
@@ -185,6 +185,7 @@ export default function StoriesBar() {
|
||||
initialUserIndex={viewerStartIndex}
|
||||
onClose={handleClose}
|
||||
token={token}
|
||||
viewerId={viewerId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
341
src/components/stories/StoryEditor.tsx
Normal file
341
src/components/stories/StoryEditor.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { StoryTextOverlay } from "@/api/fetchStories";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
const TEXT_COLORS = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
"#ff3040",
|
||||
"#ffd60a",
|
||||
"#0a84ff",
|
||||
"#30d158",
|
||||
"#ff2d92",
|
||||
"#bf5af2",
|
||||
];
|
||||
|
||||
const MIN_FONT = 0.03;
|
||||
const MAX_FONT = 0.14;
|
||||
|
||||
type StoryEditorProps = {
|
||||
mediaUrl: string;
|
||||
mediaType: "image" | "video";
|
||||
overlays: StoryTextOverlay[];
|
||||
onChange: (overlays: StoryTextOverlay[]) => void;
|
||||
};
|
||||
|
||||
export default function StoryEditor({
|
||||
mediaUrl,
|
||||
mediaType,
|
||||
overlays,
|
||||
onChange,
|
||||
}: StoryEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [draftText, setDraftText] = useState("");
|
||||
|
||||
const dragState = useRef<{
|
||||
index: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () =>
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight });
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const updateOverlay = useCallback(
|
||||
(index: number, patch: Partial<StoryTextOverlay>) => {
|
||||
onChange(
|
||||
overlays.map((o, i) => (i === index ? { ...o, ...patch } : o))
|
||||
);
|
||||
},
|
||||
[overlays, onChange]
|
||||
);
|
||||
|
||||
const removeOverlay = useCallback(
|
||||
(index: number) => {
|
||||
onChange(overlays.filter((_, i) => i !== index));
|
||||
setSelected(null);
|
||||
},
|
||||
[overlays, onChange]
|
||||
);
|
||||
|
||||
const openTextEditor = (index: number | null) => {
|
||||
if (index === null) {
|
||||
setDraftText("");
|
||||
} else {
|
||||
setDraftText(overlays[index]?.text ?? "");
|
||||
}
|
||||
setEditingIndex(index);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
const text = draftText.trim();
|
||||
if (editingIndex === null) {
|
||||
if (text) {
|
||||
const next: StoryTextOverlay = {
|
||||
text,
|
||||
x: 0.5,
|
||||
y: 0.4,
|
||||
font_size: 0.07,
|
||||
color: "#ffffff",
|
||||
background: "",
|
||||
rotation: 0,
|
||||
align: "center",
|
||||
};
|
||||
onChange([...overlays, next]);
|
||||
setSelected(overlays.length);
|
||||
}
|
||||
} else if (text) {
|
||||
updateOverlay(editingIndex, { text });
|
||||
} else {
|
||||
removeOverlay(editingIndex);
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setDraftText("");
|
||||
};
|
||||
|
||||
const handlePointerDown = (
|
||||
e: React.PointerEvent<HTMLDivElement>,
|
||||
index: number
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
dragState.current = { index, moved: false };
|
||||
setSelected(index);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const state = dragState.current;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!state || !rect) return;
|
||||
state.moved = true;
|
||||
const x = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height));
|
||||
updateOverlay(state.index, { x, y });
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
dragState.current = null;
|
||||
};
|
||||
|
||||
const selectedOverlay =
|
||||
selected !== null ? overlays[selected] : null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{/* Canvas frame 9:16 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-auto aspect-[9/16] w-full max-w-[340px] overflow-hidden rounded-2xl bg-black"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
{mediaType === "video" ? (
|
||||
<video
|
||||
src={mediaUrl}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
playsInline
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt="story"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlays.map((o, index) => {
|
||||
const fontPx = Math.max(
|
||||
10,
|
||||
o.font_size * (containerSize.width || 300)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||
onDoubleClick={() => openTextEditor(index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
color: o.color,
|
||||
fontSize: `${fontPx}px`,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
|
||||
background: o.background || "transparent",
|
||||
padding: o.background ? "0.15em 0.35em" : 0,
|
||||
borderRadius: o.background ? "0.35em" : 0,
|
||||
maxWidth: "90%",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
cursor: "move",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
textShadow: o.background
|
||||
? "none"
|
||||
: "0 1px 3px rgba(0,0,0,0.55)",
|
||||
outline:
|
||||
selected === index ? "2px dashed rgba(255,255,255,0.7)" : "none",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add-text FAB */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTextEditor(null);
|
||||
}}
|
||||
className="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="add" size={16} tinted className="text-white" />
|
||||
متن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar for selected overlay */}
|
||||
{selectedOverlay && (
|
||||
<div className="mx-auto w-full max-w-[340px] space-y-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">
|
||||
ویرایش متن
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTextEditor(selected)}
|
||||
className="rounded-md bg-neutral-100 px-2 py-1 text-xs dark:bg-neutral-800"
|
||||
>
|
||||
تغییر نوشته
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selected !== null && removeOverlay(selected)}
|
||||
className="rounded-md bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-red-950/40"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{TEXT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selected !== null && updateOverlay(selected, { color: c })
|
||||
}
|
||||
className={`h-7 w-7 rounded-full border ${
|
||||
selectedOverlay.color === c
|
||||
? "ring-2 ring-offset-2 ring-[#0095f6] dark:ring-offset-neutral-900"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Highlight background toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selected !== null &&
|
||||
updateOverlay(selected, {
|
||||
background: selectedOverlay.background ? "" : "#000000",
|
||||
})
|
||||
}
|
||||
className="rounded-md bg-neutral-100 px-3 py-1 text-xs dark:bg-neutral-800"
|
||||
>
|
||||
{selectedOverlay.background ? "حذف پسزمینه متن" : "افزودن پسزمینه متن"}
|
||||
</button>
|
||||
|
||||
{/* Font size */}
|
||||
<label className="flex items-center gap-3 text-xs text-neutral-500">
|
||||
اندازه
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT}
|
||||
max={MAX_FONT}
|
||||
step={0.005}
|
||||
value={selectedOverlay.font_size}
|
||||
onChange={(e) =>
|
||||
selected !== null &&
|
||||
updateOverlay(selected, {
|
||||
font_size: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
className="flex-1 accent-[#0095f6]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text input modal */}
|
||||
{editingIndex !== null && (
|
||||
<div className="fixed inset-0 z-[1001] flex flex-col bg-black/85 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingIndex(null);
|
||||
setDraftText("");
|
||||
}}
|
||||
className="text-sm text-white/70"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={commitText}
|
||||
className="text-sm font-semibold text-[#0095f6]"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
placeholder="متن استوری…"
|
||||
rows={3}
|
||||
className="w-full max-w-sm resize-none bg-transparent text-center text-2xl font-bold text-white outline-none placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,18 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { StoryFeedUser } from "@/api/fetchStories";
|
||||
import { markStoryViewed } from "@/api/fetchStories";
|
||||
import toast from "react-hot-toast";
|
||||
import { StoryFeedUser, StoryTextOverlay } from "@/api/fetchStories";
|
||||
import { markStoryViewed, updateStoryOverlays } from "@/api/fetchStories";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import StoryEditor from "@/components/stories/StoryEditor";
|
||||
|
||||
const IMAGE_DURATION_MS = 5000;
|
||||
|
||||
@@ -20,6 +23,7 @@ type StoryViewerProps = {
|
||||
initialUserIndex: number;
|
||||
onClose: () => void;
|
||||
token: string;
|
||||
viewerId?: string | null;
|
||||
};
|
||||
|
||||
export default function StoryViewer({
|
||||
@@ -27,11 +31,20 @@ export default function StoryViewer({
|
||||
initialUserIndex,
|
||||
onClose,
|
||||
token,
|
||||
viewerId,
|
||||
}: StoryViewerProps) {
|
||||
const [userIndex, setUserIndex] = useState(initialUserIndex);
|
||||
const [storyIndex, setStoryIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftOverlays, setDraftOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [overlayOverrides, setOverlayOverrides] = useState<
|
||||
Record<string, StoryTextOverlay[]>
|
||||
>({});
|
||||
const [mediaWidth, setMediaWidth] = useState(0);
|
||||
const mediaBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number>(0);
|
||||
@@ -79,8 +92,18 @@ export default function StoryViewer({
|
||||
markStoryViewed(currentStory._id, token);
|
||||
}, [currentStory?._id, token]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = mediaBoxRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setMediaWidth(el.clientWidth);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory || paused) return;
|
||||
if (!currentStory || paused || editing) return;
|
||||
|
||||
if (currentStory.media_type === "video") {
|
||||
const video = videoRef.current;
|
||||
@@ -116,7 +139,7 @@ export default function StoryViewer({
|
||||
return () => {
|
||||
if (timerRef.current) cancelAnimationFrame(timerRef.current);
|
||||
};
|
||||
}, [currentStory, paused, goNext]);
|
||||
}, [currentStory, paused, editing, goNext]);
|
||||
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
@@ -143,6 +166,40 @@ export default function StoryViewer({
|
||||
? currentStory.media_path
|
||||
: buildStorageUrl(currentStory.media_path);
|
||||
|
||||
const currentOverlays =
|
||||
overlayOverrides[currentStory._id] ?? currentStory.overlays ?? [];
|
||||
const isOwnStory = Boolean(viewerId && currentUser.user._id === viewerId);
|
||||
|
||||
const openEditor = () => {
|
||||
setDraftOverlays(currentOverlays.map((o) => ({ ...o })));
|
||||
setEditing(true);
|
||||
setPaused(true);
|
||||
if (videoRef.current) videoRef.current.pause();
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditing(false);
|
||||
setPaused(false);
|
||||
startRef.current = Date.now();
|
||||
};
|
||||
|
||||
const saveEditor = async () => {
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateStoryOverlays(currentStory._id, draftOverlays, token);
|
||||
setOverlayOverrides((prev) => ({
|
||||
...prev,
|
||||
[currentStory._id]: draftOverlays,
|
||||
}));
|
||||
toast.success("استوری ویرایش شد");
|
||||
closeEditor();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ویرایش استوری");
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName =
|
||||
currentUser.user.user_name ||
|
||||
[currentUser.user.first_name, currentUser.user.last_name]
|
||||
@@ -150,7 +207,7 @@ export default function StoryViewer({
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black">
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black">
|
||||
{/* Progress bars */}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 flex gap-1 px-2 pt-[calc(0.5rem+env(safe-area-inset-top))]">
|
||||
{currentUser.stories.map((_, idx) => (
|
||||
@@ -199,18 +256,31 @@ export default function StoryViewer({
|
||||
{formatStoryTime(currentStory.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center text-white"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwnStory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
className="flex h-9 items-center gap-1 rounded-full bg-white/15 px-3 text-xs font-semibold text-white backdrop-blur"
|
||||
aria-label="ویرایش استوری"
|
||||
>
|
||||
<BoldIcon name="edit" size={16} tinted className="text-white" />
|
||||
ویرایش
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center text-white"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
<div className="relative h-full w-full max-w-lg">
|
||||
<div ref={mediaBoxRef} className="relative h-full w-full max-w-lg">
|
||||
{currentStory.media_type === "video" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -229,6 +299,34 @@ export default function StoryViewer({
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text overlays */}
|
||||
{currentOverlays.map((o, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
color: o.color,
|
||||
fontSize: `${Math.max(10, o.font_size * (mediaWidth || 380))}px`,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
|
||||
background: o.background || "transparent",
|
||||
padding: o.background ? "0.15em 0.35em" : 0,
|
||||
borderRadius: o.background ? "0.35em" : 0,
|
||||
maxWidth: "90%",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
textShadow: o.background ? "none" : "0 1px 3px rgba(0,0,0,0.55)",
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tap zones */}
|
||||
@@ -250,6 +348,36 @@ export default function StoryViewer({
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
/>
|
||||
|
||||
{/* Edit overlays modal */}
|
||||
{editing && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col overflow-y-auto bg-black/95 pb-6 pt-[calc(0.75rem+env(safe-area-inset-top))]">
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="text-sm text-white/70"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-white">ویرایش استوری</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEditor}
|
||||
disabled={savingEdit}
|
||||
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
|
||||
>
|
||||
{savingEdit ? "…" : "ذخیره"}
|
||||
</button>
|
||||
</div>
|
||||
<StoryEditor
|
||||
mediaUrl={mediaSrc}
|
||||
mediaType={currentStory.media_type}
|
||||
overlays={draftOverlays}
|
||||
onChange={setDraftOverlays}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Returns true when user scrolls down past threshold (Safari-style compact header) */
|
||||
/**
|
||||
* ردیابی جهت اسکرول:
|
||||
* - compact: هنگام اسکرول (پایینتر از threshold) حالت جمعوجور
|
||||
* - hidden: هنگام اسکرول به پایین true (هدر مخفی)، هنگام اسکرول به بالا یا در ابتدای صفحه false
|
||||
*/
|
||||
export function useScrollDirection(threshold = 16) {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const lastY = useRef(0);
|
||||
const ticking = useRef(false);
|
||||
|
||||
@@ -15,10 +20,13 @@ export function useScrollDirection(threshold = 16) {
|
||||
const y = window.scrollY;
|
||||
if (y <= threshold) {
|
||||
setCompact(false);
|
||||
setHidden(false);
|
||||
} else if (y > lastY.current + 4) {
|
||||
setCompact(true);
|
||||
setHidden(true);
|
||||
} else if (y < lastY.current - 4) {
|
||||
setCompact(false);
|
||||
setCompact(true);
|
||||
setHidden(false);
|
||||
}
|
||||
lastY.current = y;
|
||||
ticking.current = false;
|
||||
@@ -35,5 +43,5 @@ export function useScrollDirection(threshold = 16) {
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, [threshold]);
|
||||
|
||||
return compact;
|
||||
return { compact, hidden };
|
||||
}
|
||||
|
||||
@@ -11,11 +11,26 @@ import {
|
||||
const SWIPE_THRESHOLD = 52;
|
||||
const MAX_DRAG = 72;
|
||||
|
||||
export function useSwipeToReply(onReply: () => void, enabled = true) {
|
||||
/** incoming: سوایپ به راست — outgoing: سوایپ به چپ (مثل اینستاگرام در RTL) */
|
||||
export function useSwipeToReply(
|
||||
onReply: () => void,
|
||||
enabled = true,
|
||||
direction: "left" | "right" = "left"
|
||||
) {
|
||||
const x = useMotionValue(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const replyOpacity = useTransform(x, [-10, -40], [0, 1]);
|
||||
const replyScale = useTransform(x, [-10, -40], [0.6, 1]);
|
||||
const swipeRight = direction === "right";
|
||||
|
||||
const replyOpacity = useTransform(
|
||||
x,
|
||||
swipeRight ? [10, 40] : [-10, -40],
|
||||
[0, 1]
|
||||
);
|
||||
const replyScale = useTransform(
|
||||
x,
|
||||
swipeRight ? [10, 40] : [-10, -40],
|
||||
[0.6, 1]
|
||||
);
|
||||
|
||||
const resetPosition = useCallback(() => {
|
||||
void animate(x, 0, {
|
||||
@@ -33,7 +48,10 @@ export function useSwipeToReply(onReply: () => void, enabled = true) {
|
||||
const onDragEnd = useCallback(
|
||||
(_: unknown, info: PanInfo) => {
|
||||
setDragging(false);
|
||||
if (info.offset.x <= -SWIPE_THRESHOLD) {
|
||||
const triggered = swipeRight
|
||||
? info.offset.x >= SWIPE_THRESHOLD
|
||||
: info.offset.x <= -SWIPE_THRESHOLD;
|
||||
if (triggered) {
|
||||
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
navigator.vibrate(12);
|
||||
}
|
||||
@@ -41,14 +59,18 @@ export function useSwipeToReply(onReply: () => void, enabled = true) {
|
||||
}
|
||||
resetPosition();
|
||||
},
|
||||
[onReply, resetPosition]
|
||||
[onReply, resetPosition, swipeRight]
|
||||
);
|
||||
|
||||
const dragProps = enabled
|
||||
? {
|
||||
drag: "x" as const,
|
||||
dragConstraints: { left: -MAX_DRAG, right: 0 },
|
||||
dragElastic: { left: 0.15, right: 0 },
|
||||
dragConstraints: swipeRight
|
||||
? { left: 0, right: MAX_DRAG }
|
||||
: { left: -MAX_DRAG, right: 0 },
|
||||
dragElastic: swipeRight
|
||||
? { left: 0, right: 0.15 }
|
||||
: { left: 0.15, right: 0 },
|
||||
dragMomentum: false,
|
||||
dragSnapToOrigin: true,
|
||||
style: { x, touchAction: "pan-y" as const },
|
||||
|
||||
Reference in New Issue
Block a user