Some checks failed
CI / test (push) Failing after 20s
- 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
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
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"
|
||
});
|
||
}
|