import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { globalSearch } from "../api/search"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import { addContact, addContactByEmail, listContacts, removeContact } from "../api/users"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { NewChatPanel } from "./NewChatPanel"; import { SettingsPanel } from "./SettingsPanel"; import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences"; export function ChatList() { const chats = useChatStore((s) => s.chats); const activeChatId = useChatStore((s) => s.activeChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const loadChats = useChatStore((s) => s.loadChats); const me = useAuthStore((s) => s.me); const logout = useAuthStore((s) => s.logout); const [search, setSearch] = useState(""); const [userResults, setUserResults] = useState([]); const [discoverResults, setDiscoverResults] = useState([]); const [messageResults, setMessageResults] = useState([]); const [archivedChats, setArchivedChats] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [tab, setTab] = useState<"all" | "people" | "groups" | "channels" | "archived">("all"); const [archivedLoading, setArchivedLoading] = useState(false); const [contactsLoading, setContactsLoading] = useState(false); const [contacts, setContacts] = useState([]); const [contactsOpen, setContactsOpen] = useState(false); const [contactsSearch, setContactsSearch] = useState(""); const [contactEmail, setContactEmail] = useState(""); const [contactEmailError, setContactEmailError] = useState(null); const [ctxChatId, setCtxChatId] = useState(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState(null); const [deleteForAll, setDeleteForAll] = useState(false); const [profileOpen, setProfileOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const sidebarRef = useRef(null); const burgerMenuRef = useRef(null); const burgerButtonRef = useRef(null); const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null; const canDeleteForEveryone = Boolean( deleteModalChat && !deleteModalChat.is_saved && ( deleteModalChat.type === "group" || deleteModalChat.type === "private" || (deleteModalChat.type === "channel" && (deleteModalChat.my_role === "owner" || deleteModalChat.my_role === "admin")) ) ); const channelMemberLeaveOnly = Boolean( deleteModalChat && deleteModalChat.type === "channel" && deleteModalChat.my_role === "member" ); useEffect(() => { void loadChats(); }, [loadChats]); useEffect(() => { let cancelled = false; void (async () => { setArchivedLoading(true); try { const rows = await getChats(undefined, true); if (!cancelled) { setArchivedChats(rows); } } catch { if (!cancelled) { setArchivedChats([]); } } finally { if (!cancelled) { setArchivedLoading(false); } } })(); return () => { cancelled = true; }; }, [tab, chats.length]); useEffect(() => { if (!contactsOpen) { return; } let cancelled = false; setContactsLoading(true); void (async () => { try { const rows = await listContacts(); if (!cancelled) { setContacts(rows); } } catch { if (!cancelled) { setContacts([]); } } finally { if (!cancelled) { setContactsLoading(false); } } })(); return () => { cancelled = true; }; }, [contactsOpen]); useEffect(() => { const term = search.trim(); if (term.replace("@", "").length < 2) { setUserResults([]); setDiscoverResults([]); setMessageResults([]); setSearchLoading(false); return; } let cancelled = false; setSearchLoading(true); void (async () => { try { const result = await globalSearch(term); if (cancelled) { return; } setUserResults(result.users); setDiscoverResults(result.chats); setMessageResults(result.messages); } catch { if (!cancelled) { setUserResults([]); setDiscoverResults([]); setMessageResults([]); } } finally { if (!cancelled) { setSearchLoading(false); } } })(); return () => { cancelled = true; }; }, [search]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") { return; } setCtxChatId(null); setCtxPos(null); setDeleteModalChatId(null); setProfileOpen(false); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); useEffect(() => { const onPointerDown = (event: MouseEvent) => { const target = event.target as Node | null; if (ctxChatId) { setCtxChatId(null); setCtxPos(null); } if (menuOpen) { const inMenu = burgerMenuRef.current?.contains(target ?? null) ?? false; const inButton = burgerButtonRef.current?.contains(target ?? null) ?? false; if (!inMenu && !inButton) { setMenuOpen(false); } } }; document.addEventListener("mousedown", onPointerDown); return () => document.removeEventListener("mousedown", onPointerDown); }, [ctxChatId, menuOpen]); useEffect(() => { applyAppearancePreferences(getAppPreferences()); }, []); async function openSavedMessages() { const saved = await getSavedMessagesChat(); const updatedChats = await getChats(); useChatStore.setState({ chats: updatedChats, activeChatId: saved.id }); } const filteredChats = chats.filter((chat) => { if (chat.archived) { return false; } if (tab === "people") { return chat.type === "private"; } if (tab === "groups") { return chat.type === "group"; } if (tab === "channels") { return chat.type === "channel"; } return true; }); const tabs: Array<{ id: "all" | "people" | "groups" | "channels" | "archived"; label: string }> = [ { id: "all", label: "All" }, { id: "people", label: "Люди" }, { id: "groups", label: "Groups" }, { id: "channels", label: "Каналы" }, { id: "archived", label: "Архив" } ]; const visibleChats = tab === "archived" ? archivedChats : filteredChats; const pinnedVisibleChats = visibleChats.filter((chat) => chat.pinned); const regularVisibleChats = visibleChats.filter((chat) => !chat.pinned); function renderChatRow(chat: (typeof visibleChats)[number]) { return ( ); } return ( ); } function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } { const pad = 8; const cursorOffset = 4; const wantedX = x + cursorOffset; const wantedY = y + cursorOffset; const safeX = Math.min(Math.max(pad, wantedX), window.innerWidth - menuWidth - pad); const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad); return { x: safeX, y: safeY }; } function chatLabel(chat: { display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string { if (chat.display_title?.trim()) return chat.display_title; if (chat.title?.trim()) return chat.title; if (chat.is_saved) return "Saved Messages"; if (chat.type === "private") return "Direct chat"; if (chat.type === "group") return "Group"; return "Channel"; } function chatDisplayNameById( chats: Array<{ id: number; display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }>, chatId: number ): string { const chat = chats.find((item) => item.id === chatId); if (!chat) { return "Unknown chat"; } return chatLabel(chat); } function chatMetaLabel(chat: { type: "private" | "group" | "channel"; is_saved?: boolean; counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null; members_count?: number | null; online_count?: number | null; subscribers_count?: number | null; }): string { if (chat.is_saved) { return "Personal cloud chat"; } if (chat.type === "private") { if (chat.counterpart_is_online) { return "online"; } if (chat.counterpart_last_seen_at) { return `last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`; } return "offline"; } if (chat.type === "group") { const members = chat.members_count ?? 0; const online = chat.online_count ?? 0; return `${members} members, ${online} online`; } const subscribers = chat.subscribers_count ?? chat.members_count ?? 0; return `${subscribers} subscribers`; } function chatAvatar(chat: { type: "private" | "group" | "channel"; is_saved?: boolean; avatar_url?: string | null; counterpart_avatar_url?: string | null; }): string | null { if (chat.is_saved) { return null; } if (chat.type === "private") { return chat.counterpart_avatar_url || null; } return chat.avatar_url || null; } function chatPreviewLabel(chat: { last_message_text?: string | null; last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null; type: "private" | "group" | "channel"; is_saved?: boolean; counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null; members_count?: number | null; online_count?: number | null; subscribers_count?: number | null; }): string { if (chat.last_message_type && chat.last_message_type !== "text") { if (chat.last_message_type === "image") return "🖼 Photo"; if (chat.last_message_type === "video") return "🎬 Video"; if (chat.last_message_type === "audio") return "🎵 Audio"; if (chat.last_message_type === "voice") return "🎤 Voice message"; if (chat.last_message_type === "file") return "📎 File"; if (chat.last_message_type === "circle_video") return "🎥 Video message"; } if (chat.last_message_text?.trim()) { return chat.last_message_text.trim(); } return chatMetaLabel(chat); } function formatChatListTime(value?: string | null): string { if (!value) { return ""; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return ""; } const now = new Date(); const sameDay = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); if (sameDay) { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" }); } function formatLastSeen(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return "recently"; } return date.toLocaleString(undefined, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); }