908 lines
37 KiB
TypeScript
908 lines
37 KiB
TypeScript
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<UserSearchItem[]>([]);
|
|
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
|
const [messageResults, setMessageResults] = useState<Message[]>([]);
|
|
const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
|
|
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<UserSearchItem[]>([]);
|
|
const [contactsOpen, setContactsOpen] = useState(false);
|
|
const [contactsSearch, setContactsSearch] = useState("");
|
|
const [contactEmail, setContactEmail] = useState("");
|
|
const [contactEmailError, setContactEmailError] = useState<string | null>(null);
|
|
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
|
|
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
|
|
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
|
|
const [deleteForAll, setDeleteForAll] = useState(false);
|
|
const [profileOpen, setProfileOpen] = useState(false);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const sidebarRef = useRef<HTMLElement | null>(null);
|
|
const burgerMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const burgerButtonRef = useRef<HTMLButtonElement | null>(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 (
|
|
<button
|
|
className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${
|
|
activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65"
|
|
}`}
|
|
key={chat.id}
|
|
onClick={() => setActiveChatId(chat.id)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
const safePos = getSafeContextPosition(e.clientX, e.clientY, 176, 56);
|
|
setCtxChatId(chat.id);
|
|
setCtxPos(safePos);
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="relative mt-0.5">
|
|
{chatAvatar(chat) ? (
|
|
<img alt="chat avatar" className="h-10 w-10 rounded-full object-cover" src={chatAvatar(chat)!} />
|
|
) : (
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
|
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
|
</div>
|
|
)}
|
|
{chat.type === "private" && chat.counterpart_is_online ? (
|
|
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-slate-900 bg-emerald-400" />
|
|
) : null}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="truncate text-sm font-semibold">{chatLabel(chat)}</p>
|
|
{(chat.unread_count ?? 0) > 0 || (chat.unread_mentions_count ?? 0) > 0 ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
{(chat.unread_mentions_count ?? 0) > 0 ? (
|
|
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-slate-950">
|
|
@
|
|
</span>
|
|
) : null}
|
|
{(chat.unread_count ?? 0) > 0 ? (
|
|
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] font-semibold text-slate-950">
|
|
{chat.unread_count}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
) : (
|
|
<span className="shrink-0 text-[11px] text-slate-400">
|
|
{formatChatListTime(chat.last_message_created_at)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="truncate text-xs text-slate-400">{chatPreviewLabel(chat)}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<aside ref={sidebarRef} className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); setMenuOpen(false); }}>
|
|
<div className="border-b border-slate-700/50 px-3 py-3">
|
|
<div className="relative mb-2 flex items-center gap-2">
|
|
<button
|
|
ref={burgerButtonRef}
|
|
className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMenuOpen((v) => !v);
|
|
}}
|
|
>
|
|
☰
|
|
</button>
|
|
{menuOpen ? (
|
|
<div
|
|
ref={burgerMenuRef}
|
|
className="absolute left-0 top-11 z-40 w-56 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setProfileOpen(true); setMenuOpen(false); }}>
|
|
My Profile
|
|
</button>
|
|
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}>
|
|
Saved Messages
|
|
</button>
|
|
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setContactsOpen(true); setMenuOpen(false); }}>
|
|
Contacts
|
|
</button>
|
|
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
|
|
Settings
|
|
</button>
|
|
<button
|
|
className="block w-full rounded px-3 py-2 text-left text-sm text-red-300 hover:bg-slate-800"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
if (!window.confirm("Log out of your account?")) {
|
|
return;
|
|
}
|
|
logout();
|
|
}}
|
|
>
|
|
Logout
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<label className="block flex-1">
|
|
<input
|
|
className="w-full rounded-full border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
|
placeholder="Search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-500/30 text-xs font-semibold uppercase text-sky-100" onClick={() => setProfileOpen(true)}>
|
|
{(me?.name || me?.username || "u").slice(0, 1)}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-3 overflow-x-auto pt-1 text-sm">
|
|
{tabs.map((item) => (
|
|
<button
|
|
className={`whitespace-nowrap pb-1.5 ${tab === item.id ? "border-b-2 border-sky-400 font-semibold text-sky-300" : "text-slate-300/80"}`}
|
|
key={item.id}
|
|
onClick={() => setTab(item.id)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{search.trim().replace("@", "").length >= 2 ? (
|
|
<div className="mt-3 rounded-xl border border-slate-700/70 bg-slate-900/70 p-2">
|
|
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
|
|
{!searchLoading && userResults.length === 0 && discoverResults.length === 0 && messageResults.length === 0 ? (
|
|
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
|
|
) : null}
|
|
{userResults.length > 0 ? (
|
|
<div className="mb-1">
|
|
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">People</p>
|
|
{userResults.slice(0, 5).map((user) => (
|
|
<div className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-800" key={`user-${user.id}`}>
|
|
<button
|
|
className="min-w-0 flex-1 text-left"
|
|
onClick={async () => {
|
|
const chat = await createPrivateChat(user.id);
|
|
const updatedChats = await getChats();
|
|
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
|
|
setSearch("");
|
|
setUserResults([]);
|
|
setDiscoverResults([]);
|
|
setMessageResults([]);
|
|
}}
|
|
type="button"
|
|
>
|
|
<p className="truncate text-xs font-semibold">{user.name}</p>
|
|
<p className="truncate text-[11px] text-slate-400">{user.email || `@${user.username}`}</p>
|
|
</button>
|
|
<button
|
|
className="rounded bg-slate-700 px-2 py-1 text-[10px] hover:bg-slate-600"
|
|
onClick={async () => {
|
|
await addContact(user.id);
|
|
if (contactsOpen) {
|
|
setContacts(await listContacts());
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{discoverResults.length > 0 ? (
|
|
<div>
|
|
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Groups and Channels</p>
|
|
{discoverResults.slice(0, 5).map((chat) => (
|
|
<button
|
|
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
|
|
key={`discover-${chat.id}`}
|
|
onClick={async () => {
|
|
if (!chat.is_member) {
|
|
await joinChat(chat.id);
|
|
}
|
|
const updatedChats = await getChats();
|
|
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
|
|
setSearch("");
|
|
setUserResults([]);
|
|
setDiscoverResults([]);
|
|
setMessageResults([]);
|
|
}}
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-xs font-semibold">{chat.display_title || chat.title || (chat.type === "group" ? "Group" : "Channel")}</p>
|
|
<p className="truncate text-[11px] text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
|
|
</div>
|
|
<span className="ml-2 shrink-0 text-[10px] text-slate-400">{chat.is_member ? "open" : "join"}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{messageResults.length > 0 ? (
|
|
<div>
|
|
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Messages</p>
|
|
{messageResults.slice(0, 5).map((message) => (
|
|
<button
|
|
className="block w-full rounded-lg px-2 py-2 text-left hover:bg-slate-800"
|
|
key={`message-${message.id}`}
|
|
onClick={async () => {
|
|
setActiveChatId(message.chat_id);
|
|
setFocusedMessage(message.chat_id, message.id);
|
|
setSearch("");
|
|
setUserResults([]);
|
|
setDiscoverResults([]);
|
|
setMessageResults([]);
|
|
}}
|
|
>
|
|
<p className="truncate text-[11px] text-slate-400">{chatDisplayNameById(chats, message.chat_id)}</p>
|
|
<p className="truncate text-xs font-semibold">{message.text || "[media]"}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="tg-scrollbar flex-1 overflow-auto">
|
|
{tab === "all" && archivedChats.length > 0 ? (
|
|
<button
|
|
className="block w-full border-b border-slate-800/60 px-4 py-3 text-left transition hover:bg-slate-800/65"
|
|
onClick={() => setTab("archived")}
|
|
type="button"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-slate-700/70 text-sm font-semibold text-slate-100">
|
|
🗂
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="truncate text-sm font-semibold">Archived Chats</p>
|
|
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-slate-700 px-1.5 py-0.5 text-[10px] font-semibold text-slate-200">
|
|
{archivedChats.length}
|
|
</span>
|
|
</div>
|
|
<p className="truncate text-xs text-slate-400">Hidden chats and channels</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
) : null}
|
|
|
|
{tab === "archived" && archivedLoading ? (
|
|
<p className="px-4 py-3 text-xs text-slate-400">Loading archived chats...</p>
|
|
) : null}
|
|
{tab === "archived" && !archivedLoading && archivedChats.length === 0 ? (
|
|
<p className="px-4 py-3 text-xs text-slate-400">Archive is empty</p>
|
|
) : null}
|
|
{pinnedVisibleChats.length > 0 ? (
|
|
<p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Pinned</p>
|
|
) : null}
|
|
{pinnedVisibleChats.map(renderChatRow)}
|
|
{regularVisibleChats.length > 0 ? (
|
|
<p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Chats</p>
|
|
) : null}
|
|
{regularVisibleChats.map(renderChatRow)}
|
|
</div>
|
|
{contactsOpen ? (
|
|
<div className="absolute inset-0 z-30 flex flex-col bg-slate-900">
|
|
<div className="border-b border-slate-700/50 px-4 py-3">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<button
|
|
className="rounded-lg bg-slate-700/70 px-2 py-1.5 text-xs"
|
|
onClick={() => {
|
|
setContactsOpen(false);
|
|
setContactsSearch("");
|
|
}}
|
|
type="button"
|
|
>
|
|
Back
|
|
</button>
|
|
<p className="text-sm font-semibold">Contacts</p>
|
|
</div>
|
|
<input
|
|
className="w-full rounded-full border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
|
placeholder="Search contacts"
|
|
value={contactsSearch}
|
|
onChange={(e) => setContactsSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="border-b border-slate-800/60 px-4 py-3">
|
|
<p className="mb-2 text-[10px] uppercase tracking-wide text-slate-400">Add contact (Email)</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
className="w-full rounded bg-slate-800 px-3 py-2 text-xs outline-none"
|
|
placeholder="name@example.com"
|
|
type="email"
|
|
value={contactEmail}
|
|
onChange={(e) => {
|
|
setContactEmail(e.target.value);
|
|
setContactEmailError(null);
|
|
}}
|
|
/>
|
|
<button
|
|
className="rounded bg-sky-500 px-3 py-2 text-xs font-semibold text-slate-950"
|
|
onClick={async () => {
|
|
const email = contactEmail.trim();
|
|
if (!email) {
|
|
return;
|
|
}
|
|
try {
|
|
await addContactByEmail(email);
|
|
setContactEmail("");
|
|
setContacts(await listContacts());
|
|
} catch {
|
|
setContactEmailError("User with this email not found");
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
{contactEmailError ? <p className="mt-1 text-[11px] text-red-400">{contactEmailError}</p> : null}
|
|
</div>
|
|
<div className="tg-scrollbar flex-1 overflow-auto">
|
|
{contactsLoading ? (
|
|
<p className="px-4 py-3 text-xs text-slate-400">Loading contacts...</p>
|
|
) : null}
|
|
{!contactsLoading &&
|
|
contacts.filter((user) => {
|
|
const term = contactsSearch.trim().toLowerCase();
|
|
if (!term) {
|
|
return true;
|
|
}
|
|
return (
|
|
(user.name || "").toLowerCase().includes(term) ||
|
|
(user.username || "").toLowerCase().includes(term) ||
|
|
(user.email || "").toLowerCase().includes(term)
|
|
);
|
|
}).length === 0 ? (
|
|
<p className="px-4 py-3 text-xs text-slate-400">No contacts yet</p>
|
|
) : null}
|
|
{!contactsLoading
|
|
? contacts
|
|
.filter((user) => {
|
|
const term = contactsSearch.trim().toLowerCase();
|
|
if (!term) {
|
|
return true;
|
|
}
|
|
return (
|
|
(user.name || "").toLowerCase().includes(term) ||
|
|
(user.username || "").toLowerCase().includes(term) ||
|
|
(user.email || "").toLowerCase().includes(term)
|
|
);
|
|
})
|
|
.map((user) => (
|
|
<div className="flex items-center gap-2 border-b border-slate-800/60 px-4 py-3" key={`contact-${user.id}`}>
|
|
<button
|
|
className="min-w-0 flex-1 text-left"
|
|
onClick={async () => {
|
|
const chat = await createPrivateChat(user.id);
|
|
const updatedChats = await getChats();
|
|
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
|
|
setContactsOpen(false);
|
|
setContactsSearch("");
|
|
}}
|
|
type="button"
|
|
>
|
|
<p className="truncate text-sm font-semibold">{user.name}</p>
|
|
<p className="truncate text-xs text-slate-400">{user.email || `@${user.username}`}</p>
|
|
</button>
|
|
<button
|
|
className="rounded bg-slate-700 px-2 py-1 text-[10px] text-red-200 hover:bg-slate-600"
|
|
onClick={async () => {
|
|
await removeContact(user.id);
|
|
setContacts((prev) => prev.filter((item) => item.id !== user.id));
|
|
}}
|
|
type="button"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))
|
|
: null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<NewChatPanel />
|
|
|
|
{ctxChatId && ctxPos
|
|
? createPortal(
|
|
<div className="fixed inset-0 z-[99]" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
|
|
<div className="fixed z-[100] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
|
onClick={() => {
|
|
setDeleteModalChatId(ctxChatId);
|
|
setCtxChatId(null);
|
|
setCtxPos(null);
|
|
setDeleteForAll(false);
|
|
}}
|
|
>
|
|
{(() => {
|
|
const chat = chats.find((c) => c.id === ctxChatId);
|
|
if (!chat) return "Delete chat";
|
|
if (chat.is_saved) return "Clear chat";
|
|
if (chat.type === "channel" && chat.my_role === "member") return "Leave channel";
|
|
return "Delete chat";
|
|
})()}
|
|
</button>
|
|
<button
|
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
|
onClick={async () => {
|
|
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
if (target.archived) {
|
|
await unarchiveChat(target.id);
|
|
} else {
|
|
await archiveChat(target.id);
|
|
}
|
|
await loadChats();
|
|
const nextArchived = await getChats(undefined, true);
|
|
setArchivedChats(nextArchived);
|
|
setCtxChatId(null);
|
|
setCtxPos(null);
|
|
}}
|
|
>
|
|
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
|
|
</button>
|
|
<button
|
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
|
onClick={async () => {
|
|
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
if (target.pinned) {
|
|
await unpinChat(target.id);
|
|
} else {
|
|
await pinChat(target.id);
|
|
}
|
|
await loadChats();
|
|
if (tab === "archived") {
|
|
const nextArchived = await getChats(undefined, true);
|
|
setArchivedChats(nextArchived);
|
|
}
|
|
setCtxChatId(null);
|
|
setCtxPos(null);
|
|
}}
|
|
>
|
|
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.pinned ? "Unpin chat" : "Pin chat"}
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)
|
|
: null}
|
|
|
|
{deleteModalChatId ? (
|
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
|
<p className="mb-2 text-sm font-semibold">
|
|
{(deleteModalChat?.is_saved
|
|
? "Clear chat"
|
|
: deleteModalChat?.type === "channel" && deleteModalChat.my_role === "member"
|
|
? "Leave channel"
|
|
: "Delete chat")}
|
|
: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}
|
|
</p>
|
|
{deleteModalChat?.type === "channel" ? (
|
|
<p className="mb-3 text-xs text-slate-400">
|
|
{channelMemberLeaveOnly
|
|
? "You will leave this channel."
|
|
: "Channel deletion removes it for all subscribers."}
|
|
</p>
|
|
) : null}
|
|
{deleteModalChat?.is_saved ? (
|
|
<p className="mb-3 text-xs text-slate-400">This will clear all messages in Saved Messages.</p>
|
|
) : null}
|
|
{canDeleteForEveryone ? (
|
|
<label className="mb-3 flex items-center gap-2 text-sm">
|
|
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
|
Delete for everyone
|
|
</label>
|
|
) : null}
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
|
|
onClick={async () => {
|
|
if (deleteModalChat?.is_saved) {
|
|
await clearChat(deleteModalChatId);
|
|
useChatStore.getState().clearChatMessages(deleteModalChatId);
|
|
setDeleteModalChatId(null);
|
|
return;
|
|
}
|
|
if (channelMemberLeaveOnly) {
|
|
await leaveChat(deleteModalChatId);
|
|
} else {
|
|
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
|
}
|
|
await loadChats();
|
|
if (activeChatId === deleteModalChatId) {
|
|
setActiveChatId(null);
|
|
}
|
|
setDeleteModalChatId(null);
|
|
}}
|
|
>
|
|
{deleteModalChat?.is_saved ? "Clear" : channelMemberLeaveOnly ? "Leave" : "Delete"}
|
|
</button>
|
|
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{profileOpen ? (
|
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
|
<p className="mb-3 text-sm font-semibold">My Profile</p>
|
|
<div className="rounded-xl border border-slate-700/70 bg-slate-800/50 p-4 text-center">
|
|
{me?.avatar_url ? (
|
|
<img alt="my avatar" className="mx-auto h-24 w-24 rounded-full object-cover" src={me.avatar_url} />
|
|
) : (
|
|
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-sky-500/30 text-2xl font-semibold uppercase text-sky-100">
|
|
{(me?.name || me?.username || "Me").slice(0, 2)}
|
|
</div>
|
|
)}
|
|
<p className="mt-3 truncate text-base font-semibold">{me?.name || "No name"}</p>
|
|
<p className="truncate text-sm text-sky-300">@{me?.username || "username"}</p>
|
|
<p className="mt-1 truncate text-xs text-slate-400">{me?.email || ""}</p>
|
|
{me?.bio ? <p className="mt-2 text-sm text-slate-300">{me.bio}</p> : null}
|
|
<p className="mt-2 text-xs text-slate-400">Profile editing is available in Settings</p>
|
|
</div>
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
className="flex-1 rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950"
|
|
onClick={() => {
|
|
setProfileOpen(false);
|
|
setSettingsOpen(true);
|
|
}}
|
|
>
|
|
Open settings
|
|
</button>
|
|
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setProfileOpen(false)}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
</aside>
|
|
);
|
|
}
|
|
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"
|
|
});
|
|
}
|