Files
Messenger/web/src/pages/ChatsPage.tsx
2026-03-08 10:35:21 +03:00

270 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from "react";
import { ChatList } from "../components/ChatList";
import { ChatInfoPanel } from "../components/ChatInfoPanel";
import { MessageComposer } from "../components/MessageComposer";
import { MessageList } from "../components/MessageList";
import { getNotifications, type NotificationItem } from "../api/notifications";
import { searchMessages } from "../api/chats";
import type { Message } from "../chat/types";
import { useRealtime } from "../hooks/useRealtime";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useState } from "react";
export function ChatsPage() {
const me = useAuthStore((s) => s.me);
const logout = useAuthStore((s) => s.logout);
const loadChats = useChatStore((s) => s.loadChats);
const activeChatId = useChatStore((s) => s.activeChatId);
const chats = useChatStore((s) => s.chats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const loadMessages = useChatStore((s) => s.loadMessages);
const activeChat = chats.find((chat) => chat.id === activeChatId);
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
const [infoOpen, setInfoOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchLoading, setSearchLoading] = useState(false);
const [searchResults, setSearchResults] = useState<Message[]>([]);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false);
useRealtime();
useEffect(() => {
void loadChats();
}, [loadChats]);
useEffect(() => {
if (activeChatId) {
void loadMessages(activeChatId);
}
}, [activeChatId, loadMessages]);
useEffect(() => {
if (!searchOpen) {
return;
}
const term = searchQuery.trim();
if (term.length < 2) {
setSearchResults([]);
setSearchLoading(false);
return;
}
let cancelled = false;
setSearchLoading(true);
void (async () => {
try {
const found = await searchMessages(term, activeChatId ?? undefined);
if (!cancelled) {
setSearchResults(found);
}
} catch {
if (!cancelled) {
setSearchResults([]);
}
} finally {
if (!cancelled) {
setSearchLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [searchOpen, searchQuery, activeChatId]);
useEffect(() => {
if (!notificationsOpen) {
return;
}
let cancelled = false;
setLoadingNotifications(true);
void (async () => {
try {
const items = await getNotifications(30);
if (!cancelled) {
setNotifications(items);
}
} catch {
if (!cancelled) {
setNotifications([]);
}
} finally {
if (!cancelled) {
setLoadingNotifications(false);
}
}
})();
return () => {
cancelled = true;
};
}, [notificationsOpen]);
return (
<main className="h-screen w-full p-2 text-text md:p-3">
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-3">
<section className={`tg-panel overflow-hidden rounded-2xl ${activeChatId ? "hidden md:block md:w-[360px]" : "w-full md:w-[360px]"}`}>
<ChatList />
</section>
<section className={`tg-panel tg-chat-wallpaper min-w-0 flex-1 overflow-hidden rounded-2xl ${activeChatId ? "flex" : "hidden md:flex"} flex-col`}>
<div className="flex h-16 items-center justify-between border-b border-slate-700/50 bg-slate-900/65 px-4">
<div className="flex min-w-0 items-center gap-3">
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
Back
</button>
{activeChatId ? (
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-700/70 text-xs font-semibold" onClick={() => setInfoOpen(true)}>
i
</button>
) : null}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}</p>
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<button
className="relative rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
onClick={() => setNotificationsOpen(true)}
>
Notifications
{notifications.length > 0 ? (
<span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
) : null}
</button>
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
Search
</button>
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
Logout
</button>
</div>
</div>
<div className="min-h-0 flex-1">
<MessageList />
</div>
{activeChatId && !isReadOnlyChannel ? (
<MessageComposer />
) : activeChatId && isReadOnlyChannel ? (
<div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
Только администраторы могут публиковать сообщения в этом канале
</div>
) : (
<div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
Выберите чат, чтобы начать переписку
</div>
)}
</section>
</div>
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} />
{searchOpen ? (
<div className="fixed inset-0 z-[130] flex items-start justify-center bg-slate-950/60 p-3 md:p-6" onClick={() => setSearchOpen(false)}>
<div className="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">Search messages</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setSearchOpen(false)}>Close</button>
</div>
<input
className="mb-2 w-full rounded-xl 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={activeChatId ? "Search in current chat..." : "Global search..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
{!searchLoading && searchQuery.trim().length >= 2 && searchResults.length === 0 ? (
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
) : null}
<div className="tg-scrollbar max-h-[50vh] space-y-1 overflow-auto">
{searchResults.map((message) => {
const chatMeta = chats.find((chat) => chat.id === message.chat_id);
const chatLabel = chatMeta?.public_id ?? String(message.chat_id);
return (
<button
className="block w-full rounded-lg bg-slate-800/80 px-3 py-2 text-left hover:bg-slate-700/80"
key={`search-msg-${message.id}`}
onClick={() => {
setActiveChatId(message.chat_id);
setFocusedMessage(message.chat_id, message.id);
setSearchOpen(false);
}}
>
<p className="mb-1 text-[11px] text-slate-400">chat {chatLabel} · msg #{message.id}</p>
<p className="line-clamp-2 text-sm text-slate-100">{message.text || "[media]"}</p>
</button>
);
})}
</div>
</div>
</div>
) : null}
{notificationsOpen ? (
<div className="fixed inset-0 z-[130] flex items-start justify-center bg-slate-950/60 p-3 md:p-6" onClick={() => setNotificationsOpen(false)}>
<div className="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">Notifications</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setNotificationsOpen(false)}>Close</button>
</div>
{loadingNotifications ? <p className="px-2 py-1 text-xs text-slate-400">Loading...</p> : null}
{!loadingNotifications && notifications.length === 0 ? <p className="px-2 py-1 text-xs text-slate-400">No notifications</p> : null}
<div className="tg-scrollbar max-h-[50vh] space-y-1 overflow-auto">
{notifications.map((item) => (
<div className="rounded-lg bg-slate-800/80 px-3 py-2" key={item.id}>
<p className="text-xs font-semibold text-slate-200">{item.event_type}</p>
<p className="text-[11px] text-slate-400">{new Date(item.created_at).toLocaleString()}</p>
</div>
))}
</div>
</div>
</div>
) : null}
</main>
);
}
function headerMetaLabel(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 "Saved Messages";
}
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 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"
});
}