import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import QRCode from "qrcode"; import { listBlockedUsers, updateMyProfile } from "../api/users"; import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats"; import { disableTwoFactor, enableTwoFactor, getTwoFactorRecoveryStatus, listSessions, regenerateTwoFactorRecoveryCodes, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth"; import { getNotifications, type NotificationItem } from "../api/notifications"; import type { AuthSession, AuthUser } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useUiStore } from "../store/uiStore"; import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences"; import { AvatarCropModal } from "./AvatarCropModal"; type SettingsPage = "main" | "general" | "notifications" | "privacy"; interface Props { open: boolean; onClose: () => void; } export function SettingsPanel({ open, onClose }: Props) { const me = useAuthStore((s) => s.me); const logout = useAuthStore((s) => s.logout); const showToast = useUiStore((s) => s.showToast); const [page, setPage] = useState("main"); const [prefs, setPrefs] = useState(() => getAppPreferences()); const [blockedCount, setBlockedCount] = useState(0); const [savingPrivacy, setSavingPrivacy] = useState(false); const [privacyPrivateMessages, setPrivacyPrivateMessages] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsError, setSessionsError] = useState(null); const [revokeAllLoading, setRevokeAllLoading] = useState(false); const [revokingSessionIds, setRevokingSessionIds] = useState([]); const [twofaCode, setTwofaCode] = useState(""); const [twofaSecret, setTwofaSecret] = useState(null); const [twofaUrl, setTwofaUrl] = useState(null); const [twofaQrUrl, setTwofaQrUrl] = useState(null); const [twofaError, setTwofaError] = useState(null); const [recoveryRemaining, setRecoveryRemaining] = useState(0); const [recoveryCodes, setRecoveryCodes] = useState([]); const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [notificationItems, setNotificationItems] = useState([]); const [notificationItemsLoading, setNotificationItemsLoading] = useState(false); const [profileDraft, setProfileDraft] = useState({ name: "", username: "", bio: "", avatarUrl: "", }); const [profileAvatarUploading, setProfileAvatarUploading] = useState(false); const [avatarCropFile, setAvatarCropFile] = useState(null); useEffect(() => { if (!me) { return; } setPrivacyPrivateMessages(me.privacy_private_messages || (me.allow_private_messages ? "everyone" : "nobody")); setPrivacyLastSeen(me.privacy_last_seen || "everyone"); setPrivacyAvatar(me.privacy_avatar || "everyone"); setPrivacyGroupInvites(me.privacy_group_invites || "everyone"); setProfileDraft({ name: me.name || "", username: me.username || "", bio: me.bio || "", avatarUrl: me.avatar_url || "", }); }, [me]); useEffect(() => { if (!twofaUrl) { setTwofaQrUrl(null); return; } let cancelled = false; void (async () => { try { const url = await QRCode.toDataURL(twofaUrl, { margin: 1, width: 192 }); if (!cancelled) { setTwofaQrUrl(url); } } catch { if (!cancelled) { setTwofaQrUrl(null); } } })(); return () => { cancelled = true; }; }, [twofaUrl]); useEffect(() => { if (!open) { return; } setPrefs(getAppPreferences()); }, [open]); useEffect(() => { if (!open || page !== "privacy") { return; } let cancelled = false; void (async () => { try { const blocked = await listBlockedUsers(); if (!cancelled) { setBlockedCount(blocked.length); } } catch { if (!cancelled) { setBlockedCount(0); } } })(); return () => { cancelled = true; }; }, [open, page]); useEffect(() => { if (!open || page !== "privacy" || !me?.twofa_enabled) { setRecoveryRemaining(0); return; } let cancelled = false; void (async () => { try { const status = await getTwoFactorRecoveryStatus(); if (!cancelled) { setRecoveryRemaining(status.remaining_codes); } } catch { if (!cancelled) { setRecoveryRemaining(0); } } })(); return () => { cancelled = true; }; }, [open, page, me?.twofa_enabled]); useEffect(() => { if (!open || page !== "notifications") { return; } let cancelled = false; setNotificationItemsLoading(true); void (async () => { try { const rows = await getNotifications(30); if (!cancelled) { setNotificationItems(rows); } } catch { if (!cancelled) { setNotificationItems([]); } } finally { if (!cancelled) { setNotificationItemsLoading(false); } } })(); return () => { cancelled = true; }; }, [open, page]); useEffect(() => { if (!open || page !== "privacy") { return; } let cancelled = false; setSessionsLoading(true); setSessionsError(null); void (async () => { try { const rows = await listSessions(); if (!cancelled) { setSessions(rows); } } catch { if (!cancelled) { setSessions([]); setSessionsError("Failed to load sessions"); } } finally { if (!cancelled) { setSessionsLoading(false); } } })(); return () => { cancelled = true; }; }, [open, page]); const title = useMemo(() => { if (page === "general") return "General"; if (page === "notifications") return "Notifications"; if (page === "privacy") return "Privacy and Security"; return "Settings"; }, [page]); if (!open || !me) { return null; } function updatePrefs(patch: Partial) { setPrefs(updateAppPreferences(patch)); } const notificationStatus = "Notification" in window ? Notification.permission : "denied"; async function uploadAvatar(file: File) { setProfileAvatarUploading(true); try { const upload = await requestUploadUrl(file); await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file); setProfileDraft((prev) => ({ ...prev, avatarUrl: upload.file_url })); showToast("Avatar uploaded"); } catch { showToast("Avatar upload failed"); } finally { setProfileAvatarUploading(false); } } async function copyRecoveryCodes(codes: string[]) { if (!codes.length) { showToast("No recovery codes to copy"); return; } try { await navigator.clipboard.writeText(codes.join("\n")); showToast("Recovery codes copied"); } catch { showToast("Failed to copy recovery codes"); } } function downloadRecoveryCodes(codes: string[]) { if (!codes.length) { showToast("No recovery codes to download"); return; } const content = `BenyaMessenger 2FA Recovery Codes\n\n${codes.join("\n")}\n`; const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "benya-recovery-codes.txt"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); showToast("Recovery codes downloaded"); } return createPortal(
{ setAvatarCropFile(null); void uploadAvatar(processedFile); }} onCancel={() => setAvatarCropFile(null)} open={Boolean(avatarCropFile)} />
, document.body ); } function renderNotificationPayload(payload: string): string { try { const parsed = JSON.parse(payload) as { text?: string; title?: string; body?: string; chat_id?: number }; return parsed.text || parsed.body || parsed.title || (parsed.chat_id ? `Chat #${parsed.chat_id}` : payload); } catch { return payload; } } function SettingsRow({ label, value, onClick }: { label: string; value: string; onClick: () => void }) { return ( ); } function SettingsTextRow({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } function RadioOption({ checked, label, onChange }: { checked: boolean; label: string; onChange: () => void }) { return ( ); } function CheckboxOption({ checked, label, onChange, disabled, }: { checked: boolean; label: string; onChange: (checked: boolean) => void; disabled?: boolean; }) { return ( ); }