Files
chat_room/web/src/app/(main)/messages/[id]/page.tsx
T
mobiniqow cba4bc30da chore: merge web repo history into monorepo at web/
git-subtree-dir: web
git-subtree-mainline: 75a0ba593d
git-subtree-split: 8f624e7416
2026-09-27 10:31:24 +03:30

202 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useEffect, useRef, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { ChevronRight, Send, User, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatApiError } from "@/lib/hooks";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import {
MessageView,
MatchView,
listMatches,
listMessages,
sendMessage,
photoUrl,
} from "@/lib/api";
import { ensureKeyPair, encryptMessage, decryptMessage } from "@/lib/e2ee";
const POLL_MS = 4000;
const UNREADABLE = "🔒 این پیام رو نمی‌تونی روی این دستگاه بخونی";
// پیام رمزنگاری‌شده رو برای نمایش رمزگشایی می‌کنه؛ پیام قدیمیِ قبل از E2EE
// همینجوری برگردونده می‌شه.
function messageText(
m: MessageView,
partnerPublicKey: string | undefined,
mySecretKey: Uint8Array | null
): string {
if (m.cipher_text && m.nonce) {
if (!mySecretKey || !partnerPublicKey) return UNREADABLE;
return decryptMessage(m.cipher_text, m.nonce, partnerPublicKey, mySecretKey) ?? UNREADABLE;
}
return m.text ?? "";
}
export default function ChatPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const matchId = Number(params.id);
const [match, setMatch] = useState<MatchView | null>(null);
const [messages, setMessages] = useState<MessageView[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshMessages = useCallback(async () => {
try {
const msgs = await listMessages(matchId);
setMessages(msgs);
} catch (err) {
setError(formatApiError(err));
}
}, [matchId]);
useEffect(() => {
listMatches()
.then((matches) => {
const found = matches.find((m) => m.id === matchId);
setMatch(found ?? null);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshMessages();
ensureKeyPair()
.then((kp) => setMySecretKey(kp.secretKey))
.catch(() => {});
pollRef.current = setInterval(refreshMessages, POLL_MS);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [matchId, refreshMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages?.length]);
const canEncrypt = !!mySecretKey && !!match?.partner_public_key;
async function handleSend() {
const text = draft.trim();
if (!text || sending || !mySecretKey || !match?.partner_public_key) return;
setSending(true);
setDraft("");
try {
const { cipherText, nonce } = encryptMessage(text, match.partner_public_key, mySecretKey);
const msg = await sendMessage(matchId, cipherText, nonce);
setMessages((prev) => (prev ? [...prev, msg] : [msg]));
} catch (err) {
setError(formatApiError(err));
} finally {
setSending(false);
}
}
if (error && !messages) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<ErrorBanner message={error} />
<button
onClick={() => router.push("/messages")}
className="text-[13px] font-bold text-(--color-brand-600)"
>
برگرد به پیام‌ها
</button>
</div>
);
}
if (!messages) return <FullScreenLoader />;
return (
<div className="flex flex-col min-h-full">
<header className="flex items-center gap-3 px-4 pt-[calc(env(safe-area-inset-top)+12px)] pb-3 border-b border-(--color-border-subtle) sticky top-0 bg-(--color-surface-default) z-10">
<button
onClick={() => router.push("/messages")}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) shrink-0"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<button
onClick={() => match && router.push(`/home/${match.user_id}`)}
className="flex items-center gap-2.5 flex-1 min-w-0"
>
<div className="w-10 h-10 rounded-full overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center shrink-0">
{match?.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl(match.photos[0])} alt={match.first_name} className="w-full h-full object-cover" />
) : (
<User size={18} className="text-(--color-content-disabled)" />
)}
</div>
<p className="font-bold text-[15px] text-(--color-content-primary) truncate">
{match?.first_name ?? "..."}
</p>
</button>
</header>
<div className="flex-1 flex flex-col gap-3 px-4 py-4 overflow-y-auto">
<p className="flex items-center justify-center gap-1.5 text-[11px] text-(--color-content-tertiary) text-center py-2 px-4 mx-auto">
<Lock size={11} />
پیام‌ها با رمزنگاری سرتاسری محافظت می‌شن؛ هیچ‌کس جز شما دو نفر، حتی ادمین‌های دلدل، نمی‌تونه بخوندشون.
</p>
{messages.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8">
هنوز پیامی نیست؛ اولین نفر باش که سلام می‌ده 👋
</p>
) : (
messages.map((m) => (
<div
key={m.id}
className={cn("flex flex-col max-w-[75%]", m.is_mine ? "self-start items-start" : "self-end items-end")}
>
<div
className={cn(
"px-4 py-2.5 text-[14px] leading-6",
m.is_mine
? "bg-(--color-brand-500) text-white rounded-2xl rounded-br-md"
: "bg-(--color-bg-secondary) text-(--color-content-primary) rounded-2xl rounded-bl-md"
)}
>
{messageText(m, match?.partner_public_key, mySecretKey)}
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
<ErrorBanner message={error} />
{!canEncrypt && (
<p className="text-[12px] text-(--color-content-tertiary) text-center px-4 pb-1 flex items-center justify-center gap-1.5">
<Lock size={12} />
طرف مقابل هنوز رمزنگاری سرتاسری رو فعال نکرده؛ تا فعال نشه نمی‌تونی پیام بفرستی.
</p>
)}
<div className="flex items-center gap-2 px-4 py-3 border-t border-(--color-border-subtle) pb-[calc(env(safe-area-inset-bottom)+12px)]">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="پیامت رو بنویس..."
disabled={!canEncrypt}
className="flex-1 h-11 rounded-(--radius-full) bg-(--color-bg-secondary) px-4 text-[14px] focus:outline-none focus:ring-2 focus:ring-(--color-brand-200) disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!draft.trim() || sending || !canEncrypt}
className="w-11 h-11 rounded-full bg-(--color-brand-500) text-white flex items-center justify-center disabled:opacity-40 shrink-0"
>
<Send size={17} />
</button>
</div>
</div>
);
}