feat(web): inline chat search and global audio bar
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
This commit is contained in:
2026-03-08 11:21:57 +03:00
parent 03bf197949
commit 14610b5699
7 changed files with 514 additions and 117 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { ChatList } from "../components/ChatList";
import { ChatInfoPanel } from "../components/ChatInfoPanel";
import { MessageComposer } from "../components/MessageComposer";
@@ -8,6 +8,7 @@ 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";
@@ -27,9 +28,17 @@ export function ChatsPage() {
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();
@@ -76,6 +85,36 @@ export function ChatsPage() {
};
}, [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;
@@ -112,38 +151,125 @@ export function ChatsPage() {
<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
{!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>
) : null}
<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 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>
<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>
<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>
) : null}
<div className="min-h-0 flex-1">
<MessageList />
</div>
@@ -161,46 +287,6 @@ export function ChatsPage() {
</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()}>