chore: merge web repo history into monorepo at web/

git-subtree-dir: web
git-subtree-mainline: 75a0ba593d
git-subtree-split: 8f624e7416
This commit is contained in:
2026-09-27 10:31:24 +03:30
76 changed files with 13269 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
"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>
);
}