Files
Messenger/web/src/components/ChatList.tsx
benya 14610b5699
Some checks failed
CI / test (push) Failing after 20s
feat(web): inline chat search and global audio bar
- replace modal message search with header inline search controls

- add global top audio bar linked to active inline audio player

- improve chat info header variants and light theme readability
2026-03-08 11:21:57 +03:00

639 lines
27 KiB
TypeScript

import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } 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 [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 [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 [profileName, setProfileName] = useState("");
const [profileUsername, setProfileUsername] = useState("");
const [profileBio, setProfileBio] = useState("");
const [profileAvatarUrl, setProfileAvatarUrl] = useState("");
const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true);
const [profileError, setProfileError] = useState<string | null>(null);
const [profileSaving, setProfileSaving] = useState(false);
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
const canDeleteForEveryone = Boolean(
deleteModalChat &&
!deleteModalChat.is_saved &&
(deleteModalChat.type === "group" || deleteModalChat.type === "private")
);
useEffect(() => {
void loadChats();
}, [loadChats]);
useEffect(() => {
if (tab !== "archived") {
return;
}
let cancelled = false;
void (async () => {
try {
const rows = await getChats(undefined, true);
if (!cancelled) {
setArchivedChats(rows);
}
} catch {
if (!cancelled) {
setArchivedChats([]);
}
}
})();
return () => {
cancelled = true;
};
}, [tab]);
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(() => {
applyAppearancePreferences(getAppPreferences());
}, []);
useEffect(() => {
if (!me) {
return;
}
setProfileName(me.name || "");
setProfileUsername(me.username || "");
setProfileBio(me.bio || "");
setProfileAvatarUrl(me.avatar_url || "");
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
}, [me]);
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;
return (
<aside 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
className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
>
</button>
{menuOpen ? (
<div
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={() => { setTab("people"); 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 hover:bg-slate-800" onClick={() => setMenuOpen(false)}>
More
</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) => (
<button
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
key={`user-${user.id}`}
onClick={async () => {
const chat = await createPrivateChat(user.id);
const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
setSearch("");
setUserResults([]);
setDiscoverResults([]);
setMessageResults([]);
}}
>
<p className="truncate text-xs font-semibold">{user.name}</p>
<p className="truncate text-[11px] text-slate-400">@{user.username}</p>
</button>
))}
</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">
{visibleChats.map((chat) => (
<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">
<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 ? (
<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>
) : (
<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>
))}
</div>
<NewChatPanel />
{ctxChatId && ctxPos
? createPortal(
<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);
}}
>
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "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>,
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" : "Delete chat"}: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}
</p>
{deleteModalChat?.type === "channel" ? (
<p className="mb-3 text-xs text-slate-400">Channels are removed 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;
}
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
await loadChats();
if (activeChatId === deleteModalChatId) {
setActiveChatId(null);
}
setDeleteModalChatId(null);
}}
>
{deleteModalChat?.is_saved ? "Clear" : "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-2 text-sm font-semibold">Edit profile</p>
<div className="space-y-2">
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Name" value={profileName} onChange={(e) => setProfileName(e.target.value)} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Username" value={profileUsername} onChange={(e) => setProfileUsername(e.target.value.replace("@", ""))} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Bio (optional)" value={profileBio} onChange={(e) => setProfileBio(e.target.value)} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Avatar URL (optional)" value={profileAvatarUrl} onChange={(e) => setProfileAvatarUrl(e.target.value)} />
<label className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm">
<input
type="checkbox"
checked={profileAllowPrivateMessages}
onChange={(e) => setProfileAllowPrivateMessages(e.target.checked)}
/>
Allow private messages
</label>
</div>
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
<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 disabled:opacity-60"
disabled={profileSaving}
onClick={async () => {
setProfileSaving(true);
setProfileError(null);
try {
const updated = await updateMyProfile({
name: profileName.trim() || undefined,
username: profileUsername.trim() || undefined,
bio: profileBio.trim() || null,
avatar_url: profileAvatarUrl.trim() || null,
allow_private_messages: profileAllowPrivateMessages
});
useAuthStore.setState({ me: updated });
await loadChats();
setProfileOpen(false);
} catch {
setProfileError("Failed to update profile");
} finally {
setProfileSaving(false);
}
}}
>
Save
</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 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"
});
}