Files
chat_room/src/app/(main)/messages/[id]/page.tsx
T
mobiniqow 0118ecc63a Add end-to-end encryption for chat messages (X25519 / NaCl box)
Each device generates its own X25519 key pair on login (tweetnacl); the
private key never leaves localStorage or reaches our server. Messages are
encrypted client-side with the recipient's public key before being sent,
so the backend and anyone intercepting traffic only ever see ciphertext.

- src/lib/e2ee.ts: key generation/registration + encrypt/decrypt (NaCl box)
- Chat thread + matches list: encrypt on send, decrypt on receive/preview,
  with a graceful fallback when a message can't be decrypted on this device
  (e.g. after switching browsers) and full backward compat for legacy
  plaintext messages sent before this change
- Privacy settings page explains the guarantee and its one deliberate
  trade-off: there is no key backup/escrow, so clearing browser data or
  switching devices makes old messages permanently unreadable

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 18:58:21 +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>
);
}