Files
Messenger/web/src/pages/ChatsPage.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

356 lines
14 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, useRef } 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 { useAudioPlayerStore } from "../store/audioPlayerStore";
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 [searchActiveIndex, setSearchActiveIndex] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const activeTrack = useAudioPlayerStore((s) => s.track);
const isAudioPlaying = useAudioPlayerStore((s) => s.isPlaying);
const audioVolume = useAudioPlayerStore((s) => s.volume);
const toggleAudioPlay = useAudioPlayerStore((s) => s.togglePlay);
const stopAudio = useAudioPlayerStore((s) => s.stop);
const audioEl = useAudioPlayerStore((s) => s.audioEl);
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 (!searchOpen) {
return;
}
const timer = window.setTimeout(() => searchInputRef.current?.focus(), 30);
return () => window.clearTimeout(timer);
}, [searchOpen]);
useEffect(() => {
if (!searchOpen) {
return;
}
if (searchResults.length === 0) {
setSearchActiveIndex(0);
return;
}
setSearchActiveIndex((prev) => Math.min(prev, searchResults.length - 1));
}, [searchResults, searchOpen]);
useEffect(() => {
if (!searchOpen || searchResults.length === 0) {
return;
}
const current = searchResults[searchActiveIndex];
if (!current) {
return;
}
setFocusedMessage(current.chat_id, current.id);
}, [searchOpen, searchActiveIndex, searchResults, setFocusedMessage]);
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">
{!searchOpen ? (
<>
<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);
setSearchQuery("");
setSearchResults([]);
setSearchActiveIndex(0);
}}
>
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 className="flex w-full items-center gap-2">
<input
ref={searchInputRef}
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={activeChatId ? "Search in this chat..." : "Search..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<span className="whitespace-nowrap text-xs text-slate-300">
{searchLoading
? "..."
: searchResults.length > 0
? `${searchActiveIndex + 1}/${searchResults.length}`
: "0/0"}
</span>
<button
className="rounded bg-slate-700/70 px-2 py-1 text-xs disabled:opacity-50"
disabled={searchResults.length === 0}
onClick={() =>
setSearchActiveIndex((prev) =>
searchResults.length ? (prev <= 0 ? searchResults.length - 1 : prev - 1) : 0
)
}
type="button"
>
</button>
<button
className="rounded bg-slate-700/70 px-2 py-1 text-xs disabled:opacity-50"
disabled={searchResults.length === 0}
onClick={() =>
setSearchActiveIndex((prev) =>
searchResults.length ? (prev >= searchResults.length - 1 ? 0 : prev + 1) : 0
)
}
type="button"
>
</button>
<button
className="rounded bg-slate-700/70 px-2 py-1 text-xs"
onClick={() => {
setSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
setSearchActiveIndex(0);
}}
type="button"
>
</button>
</div>
)}
</div>
{activeTrack ? (
<div className="flex h-10 items-center justify-between border-b border-slate-700/40 bg-slate-900/80 px-3 text-xs">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-200">{activeTrack.title}</p>
</div>
<div className="ml-3 flex items-center gap-2">
<button className="rounded bg-slate-700/70 px-2 py-1" onClick={() => { if (audioEl) audioEl.currentTime = Math.max(0, audioEl.currentTime - 10); }} type="button">
</button>
<button className="rounded bg-slate-700/70 px-2 py-1" onClick={() => void toggleAudioPlay()} type="button">
{isAudioPlaying ? "❚❚" : "▶"}
</button>
<button className="rounded bg-slate-700/70 px-2 py-1" onClick={() => { if (audioEl && audioEl.duration) audioEl.currentTime = Math.min(audioEl.duration, audioEl.currentTime + 10); }} type="button">
</button>
<span className="text-[11px] text-slate-400">🔊 {Math.round(audioVolume * 100)}%</span>
<button className="rounded bg-slate-700/70 px-2 py-1" onClick={stopAudio} type="button">
</button>
</div>
</div>
) : null}
<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)} />
{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"
});
}