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

@@ -35,6 +35,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [chat, setChat] = useState<ChatDetail | null>(null); const [chat, setChat] = useState<ChatDetail | null>(null);
const [members, setMembers] = useState<ChatMember[]>([]); const [members, setMembers] = useState<ChatMember[]>([]);
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({}); const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
const [counterpartProfile, setCounterpartProfile] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [titleDraft, setTitleDraft] = useState(""); const [titleDraft, setTitleDraft] = useState("");
@@ -116,16 +117,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
} }
if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) { if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) {
try { try {
const counterpart = await getUserById(detail.counterpart_user_id);
if (!cancelled) {
setCounterpartProfile(counterpart);
}
const blocked = await listBlockedUsers(); const blocked = await listBlockedUsers();
if (!cancelled) { if (!cancelled) {
setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id)); setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id));
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setCounterpartProfile(null);
setCounterpartBlocked(false); setCounterpartBlocked(false);
} }
} }
} else if (!cancelled) { } else if (!cancelled) {
setCounterpartProfile(null);
setCounterpartBlocked(false); setCounterpartBlocked(false);
} }
await refreshMembers(chatId); await refreshMembers(chatId);
@@ -204,6 +211,32 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
{chat ? ( {chat ? (
<> <>
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-4">
<div className="flex items-center gap-3">
{chat.type === "private" && counterpartProfile?.avatar_url ? (
<img alt="avatar" className="h-16 w-16 rounded-full object-cover" src={counterpartProfile.avatar_url} />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-sky-500/30 text-xl font-semibold uppercase text-sky-100">
{initialsFromName(chat.display_title || chat.title || counterpartProfile?.name || chat.type)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-base font-semibold">{chatLabel(chat)}</p>
<p className="truncate text-xs text-slate-400">
{chat.type === "private"
? privateChatStatusLabel(chat)
: chat.type === "group"
? `${chat.members_count ?? members.length} members, ${chat.online_count ?? 0} online`
: `${chat.subscribers_count ?? chat.members_count ?? members.length} subscribers`}
</p>
</div>
</div>
{chat.type === "private" && counterpartProfile?.username ? <p className="mt-3 text-xs text-sky-300">@{counterpartProfile.username}</p> : null}
{chat.type !== "private" && chat.handle ? <p className="mt-3 text-xs text-sky-300">@{chat.handle}</p> : null}
{chat.type === "private" && counterpartProfile?.bio ? <p className="mt-2 text-sm text-slate-300">{counterpartProfile.bio}</p> : null}
{chat.type !== "private" && chat.description ? <p className="mt-2 text-sm text-slate-300">{chat.description}</p> : null}
</div>
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3"> <div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs text-slate-400">Notifications</p> <p className="text-xs text-slate-400">Notifications</p>
@@ -226,37 +259,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</button> </button>
</div> </div>
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p> <p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
<p className="text-xs text-slate-400">Type</p>
<p className="text-sm">{chat.type}</p>
<p className="mt-2 text-xs text-slate-400">Title</p>
<input
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
disabled={!isGroupLike}
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
/>
{isGroupLike ? ( {isGroupLike ? (
<button <>
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60" <p className="mt-2 text-xs text-slate-400">Title</p>
disabled={savingTitle || !titleDraft.trim()} <input
onClick={async () => { className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
setSavingTitle(true); value={titleDraft}
try { onChange={(e) => setTitleDraft(e.target.value)}
const updated = await updateChatTitle(chatId, titleDraft.trim()); />
setChat((prev) => (prev ? { ...prev, ...updated } : prev)); <button
await loadChats(); className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
} catch { disabled={savingTitle || !titleDraft.trim()}
setError("Failed to update title"); onClick={async () => {
} finally { setSavingTitle(true);
setSavingTitle(false); try {
} const updated = await updateChatTitle(chatId, titleDraft.trim());
}} setChat((prev) => (prev ? { ...prev, ...updated } : prev));
> await loadChats();
Save title } catch {
</button> setError("Failed to update title");
} finally {
setSavingTitle(false);
}
}}
>
Save title
</button>
</>
) : null} ) : null}
{chat.handle ? <p className="mt-2 text-xs text-slate-300">@{chat.handle}</p> : null}
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
{isGroupLike && canManageMembers ? ( {isGroupLike && canManageMembers ? (
<div className="mt-2"> <div className="mt-2">
<button <button
@@ -760,6 +790,37 @@ function formatLastSeen(value: string): string {
}); });
} }
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 privateChatStatusLabel(chat: { counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null }): string {
if (chat.counterpart_is_online) {
return "online";
}
if (chat.counterpart_last_seen_at) {
return `last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`;
}
return "offline";
}
function initialsFromName(value: string): string {
const prepared = value.trim();
if (!prepared) {
return "?";
}
const parts = prepared.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
}
return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
}
function formatBytes(size: number): string { function formatBytes(size: number): string {
if (size < 1024) return `${size} B`; if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;

View File

@@ -289,7 +289,7 @@ export function ChatList() {
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Messages</p> <p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Messages</p>
{messageResults.slice(0, 5).map((message) => ( {messageResults.slice(0, 5).map((message) => (
<button <button
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800" className="block w-full rounded-lg px-2 py-2 text-left hover:bg-slate-800"
key={`message-${message.id}`} key={`message-${message.id}`}
onClick={async () => { onClick={async () => {
setActiveChatId(message.chat_id); setActiveChatId(message.chat_id);
@@ -300,7 +300,7 @@ export function ChatList() {
setMessageResults([]); setMessageResults([]);
}} }}
> >
<p className="truncate text-[11px] text-slate-400">chat #{message.chat_id}</p> <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> <p className="truncate text-xs font-semibold">{message.text || "[media]"}</p>
</button> </button>
))} ))}
@@ -539,6 +539,17 @@ function chatLabel(chat: { display_title?: string | null; title: string | null;
return "Channel"; 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: { function chatMetaLabel(chat: {
type: "private" | "group" | "channel"; type: "private" | "group" | "channel";
is_saved?: boolean; is_saved?: boolean;

View File

@@ -10,6 +10,7 @@ import {
import type { Message, MessageReaction } from "../chat/types"; import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useAudioPlayerStore } from "../store/audioPlayerStore";
import { useUiStore } from "../store/uiStore"; import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format"; import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage"; import { formatMessageHtml } from "../utils/formatMessage";
@@ -796,7 +797,7 @@ function renderMessageContent(
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span> <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
<span className="font-semibold">Voice message</span> <span className="font-semibold">Voice message</span>
</div> </div>
<AudioInlinePlayer src={text} /> <AudioInlinePlayer src={text} title="Voice message" />
</div> </div>
); );
} }
@@ -814,7 +815,7 @@ function renderMessageContent(
<p className="text-[11px] text-slate-400">Audio file</p> <p className="text-[11px] text-slate-400">Audio file</p>
</div> </div>
</div> </div>
<AudioInlinePlayer src={text} /> <AudioInlinePlayer src={text} title={extractFileName(text)} />
</div> </div>
); );
} }
@@ -915,8 +916,12 @@ async function downloadFileFromUrl(url: string): Promise<void> {
window.URL.revokeObjectURL(blobUrl); window.URL.revokeObjectURL(blobUrl);
} }
function AudioInlinePlayer({ src }: { src: string }) { function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const activate = useAudioPlayerStore((s) => s.activate);
const detach = useAudioPlayerStore((s) => s.detach);
const setPlayingState = useAudioPlayerStore((s) => s.setPlaying);
const setVolumeState = useAudioPlayerStore((s) => s.setVolume);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [position, setPosition] = useState(0); const [position, setPosition] = useState(0);
@@ -934,17 +939,30 @@ function AudioInlinePlayer({ src }: { src: string }) {
}; };
const onEnded = () => { const onEnded = () => {
setIsPlaying(false); setIsPlaying(false);
setPlayingState(audio, false);
};
const onPlay = () => {
activate(audio, { src, title });
setPlayingState(audio, true);
};
const onPause = () => {
setPlayingState(audio, false);
}; };
audio.addEventListener("loadedmetadata", onLoaded); audio.addEventListener("loadedmetadata", onLoaded);
audio.addEventListener("timeupdate", onTime); audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnded); audio.addEventListener("ended", onEnded);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
return () => { return () => {
audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("loadedmetadata", onLoaded);
audio.removeEventListener("timeupdate", onTime); audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnded); audio.removeEventListener("ended", onEnded);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
detach(audio);
}; };
}, []); }, [activate, detach, setPlayingState, src, title]);
async function togglePlay() { async function togglePlay() {
const audio = audioRef.current; const audio = audioRef.current;
@@ -955,6 +973,7 @@ function AudioInlinePlayer({ src }: { src: string }) {
return; return;
} }
try { try {
activate(audio, { src, title });
await audio.play(); await audio.play();
setIsPlaying(true); setIsPlaying(true);
} catch { } catch {
@@ -974,6 +993,7 @@ function AudioInlinePlayer({ src }: { src: string }) {
if (!audio) return; if (!audio) return;
audio.volume = nextValue; audio.volume = nextValue;
setVolume(nextValue); setVolume(nextValue);
setVolumeState(audio, nextValue);
} }
return ( return (

View File

@@ -58,19 +58,19 @@ body {
} }
html[data-theme="light"] { html[data-theme="light"] {
--bm-bg-primary: #eef3fb; --bm-bg-primary: #eaf1fb;
--bm-bg-secondary: #f5f8fd; --bm-bg-secondary: #f3f7fd;
--bm-bg-tertiary: #ffffff; --bm-bg-tertiary: #fbfdff;
--bm-text-color: #0f172a; --bm-text-color: #0f172a;
--bm-panel-bg: rgba(255, 255, 255, 0.93); --bm-panel-bg: rgba(255, 255, 255, 0.96);
--bm-panel-border: rgba(15, 23, 42, 0.12); --bm-panel-border: rgba(15, 23, 42, 0.14);
} }
html[data-theme="light"] .tg-chat-wallpaper { html[data-theme="light"] .tg-chat-wallpaper {
background: background:
radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.08), transparent 30%), radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 32%),
radial-gradient(circle at 86% 74%, rgba(34, 197, 94, 0.06), transparent 33%), radial-gradient(circle at 86% 74%, rgba(14, 165, 233, 0.06), transparent 35%),
linear-gradient(160deg, rgba(15, 23, 42, 0.01) 0%, rgba(15, 23, 42, 0.02) 100%); linear-gradient(160deg, rgba(148, 163, 184, 0.08) 0%, rgba(148, 163, 184, 0.03) 100%);
} }
html[data-theme="light"] .bg-slate-900\/95, html[data-theme="light"] .bg-slate-900\/95,
@@ -78,12 +78,22 @@ html[data-theme="light"] .bg-slate-900\/90,
html[data-theme="light"] .bg-slate-900\/80, html[data-theme="light"] .bg-slate-900\/80,
html[data-theme="light"] .bg-slate-900\/70, html[data-theme="light"] .bg-slate-900\/70,
html[data-theme="light"] .bg-slate-900\/60, html[data-theme="light"] .bg-slate-900\/60,
html[data-theme="light"] .bg-slate-900, html[data-theme="light"] .bg-slate-900 {
background-color: rgba(255, 255, 255, 0.97) !important;
}
html[data-theme="light"] .bg-slate-800\/80, html[data-theme="light"] .bg-slate-800\/80,
html[data-theme="light"] .bg-slate-800\/70, html[data-theme="light"] .bg-slate-800\/70,
html[data-theme="light"] .bg-slate-800\/60, html[data-theme="light"] .bg-slate-800\/60,
html[data-theme="light"] .bg-slate-800 { html[data-theme="light"] .bg-slate-800 {
background-color: rgba(255, 255, 255, 0.92) !important; background-color: rgba(241, 245, 249, 0.95) !important;
}
html[data-theme="light"] .bg-slate-700\/80,
html[data-theme="light"] .bg-slate-700\/70,
html[data-theme="light"] .bg-slate-700\/60,
html[data-theme="light"] .bg-slate-700 {
background-color: rgba(226, 232, 240, 0.95) !important;
} }
html[data-theme="light"] .border-slate-700\/80, html[data-theme="light"] .border-slate-700\/80,
@@ -91,12 +101,147 @@ html[data-theme="light"] .border-slate-700\/70,
html[data-theme="light"] .border-slate-700\/60, html[data-theme="light"] .border-slate-700\/60,
html[data-theme="light"] .border-slate-700\/50, html[data-theme="light"] .border-slate-700\/50,
html[data-theme="light"] .border-slate-700 { html[data-theme="light"] .border-slate-700 {
border-color: rgba(15, 23, 42, 0.14) !important; border-color: rgba(71, 85, 105, 0.28) !important;
}
html[data-theme="light"] .text-slate-100 {
color: #0f172a !important;
}
html[data-theme="light"] .text-slate-200 {
color: #1e293b !important;
}
html[data-theme="light"] .text-slate-300 {
color: #334155 !important;
}
html[data-theme="light"] .text-slate-400 {
color: #64748b !important;
}
html[data-theme="light"] .text-slate-500 {
color: #94a3b8 !important;
}
html[data-theme="light"] .hover\:bg-slate-800\/70:hover,
html[data-theme="light"] .hover\:bg-slate-800\/65:hover,
html[data-theme="light"] .hover\:bg-slate-800\/60:hover,
html[data-theme="light"] .hover\:bg-slate-800:hover {
background-color: rgba(226, 232, 240, 0.95) !important;
}
html[data-theme="light"] .hover\:bg-slate-700\/80:hover,
html[data-theme="light"] .hover\:bg-slate-700:hover {
background-color: rgba(203, 213, 225, 0.9) !important;
}
html[data-theme="light"] .tg-panel {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
html[data-theme="light"] .bg-slate-950\/55,
html[data-theme="light"] .bg-slate-950\/60,
html[data-theme="light"] .bg-slate-950\/90 {
background-color: rgba(15, 23, 42, 0.28) !important;
}
html[data-theme="light"] input,
html[data-theme="light"] textarea,
html[data-theme="light"] select {
color: #0f172a;
}
html[data-theme="light"] input::placeholder,
html[data-theme="light"] textarea::placeholder {
color: #64748b;
}
html[data-theme="light"] .text-slate-950,
html[data-theme="light"] .text-black {
color: #0f172a !important;
}
html[data-theme="light"] .bg-sky-500\/30 {
background-color: rgba(14, 165, 233, 0.2) !important;
}
html[data-theme="light"] .text-sky-100 {
color: #0369a1 !important;
}
html[data-theme="light"] .text-sky-300 {
color: #075985 !important;
}
html[data-theme="light"] .text-red-400,
html[data-theme="light"] .text-red-300 {
color: #b91c1c !important;
}
html[data-theme="light"] .text-amber-300 {
color: #a16207 !important;
}
html[data-theme="light"] .text-emerald-400 {
color: #047857 !important;
}
html[data-theme="light"] .border-slate-800\/60,
html[data-theme="light"] .border-slate-800,
html[data-theme="light"] .border-slate-900 {
border-color: rgba(71, 85, 105, 0.2) !important;
}
html[data-theme="light"] .bg-slate-900\/50 {
background-color: rgba(248, 250, 252, 0.95) !important;
}
html[data-theme="light"] .bg-slate-800\/50 {
background-color: rgba(241, 245, 249, 0.88) !important;
}
html[data-theme="light"] .text-slate-600 {
color: #475569 !important;
}
html[data-theme="light"] .text-slate-700 {
color: #334155 !important;
}
html[data-theme="light"] .text-slate-800 {
color: #1e293b !important;
}
html[data-theme="light"] .text-slate-900 {
color: #0f172a !important;
}
html[data-theme="light"] .bg-white {
background-color: #ffffff !important;
}
html[data-theme="light"] .bg-slate-100 {
background-color: #f1f5f9 !important;
}
html[data-theme="light"] .bg-slate-200 {
background-color: #e2e8f0 !important;
}
html[data-theme="light"] .border-slate-600,
html[data-theme="light"] .border-slate-500 {
border-color: rgba(100, 116, 139, 0.35) !important;
}
html[data-theme="light"] .ring-slate-700,
html[data-theme="light"] .ring-slate-600 {
--tw-ring-color: rgba(100, 116, 139, 0.35) !important;
} }
html[data-theme="light"] .text-slate-100, html[data-theme="light"] .text-slate-100,
html[data-theme="light"] .text-slate-200, html[data-theme="light"] .text-slate-200,
html[data-theme="light"] .text-slate-300, html[data-theme="light"] .text-slate-300,
html[data-theme="light"] .text-slate-400 { html[data-theme="light"] .text-slate-400 {
color: #334155 !important; text-shadow: none;
} }

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { ChatList } from "../components/ChatList"; import { ChatList } from "../components/ChatList";
import { ChatInfoPanel } from "../components/ChatInfoPanel"; import { ChatInfoPanel } from "../components/ChatInfoPanel";
import { MessageComposer } from "../components/MessageComposer"; import { MessageComposer } from "../components/MessageComposer";
@@ -8,6 +8,7 @@ import { searchMessages } from "../api/chats";
import type { Message } from "../chat/types"; import type { Message } from "../chat/types";
import { useRealtime } from "../hooks/useRealtime"; import { useRealtime } from "../hooks/useRealtime";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useAudioPlayerStore } from "../store/audioPlayerStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useState } from "react"; import { useState } from "react";
@@ -27,9 +28,17 @@ export function ChatsPage() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [searchResults, setSearchResults] = useState<Message[]>([]); const [searchResults, setSearchResults] = useState<Message[]>([]);
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<NotificationItem[]>([]); const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false); 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(); useRealtime();
@@ -76,6 +85,36 @@ export function ChatsPage() {
}; };
}, [searchOpen, searchQuery, activeChatId]); }, [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(() => { useEffect(() => {
if (!notificationsOpen) { if (!notificationsOpen) {
return; 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`}> <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 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"> {!searchOpen ? (
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}> <>
Back <div className="flex min-w-0 items-center gap-3">
</button> <button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
{activeChatId ? ( Back
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-700/70 text-xs font-semibold" onClick={() => setInfoOpen(true)}> </button>
i {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>
) : 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"> <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 font-semibold text-slate-200">{activeTrack.title}</p>
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</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> </div>
<div className="flex items-center gap-1.5"> ) : null}
<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"> <div className="min-h-0 flex-1">
<MessageList /> <MessageList />
</div> </div>
@@ -161,46 +287,6 @@ export function ChatsPage() {
</section> </section>
</div> </div>
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} /> <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 ? ( {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="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="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>

View File

@@ -0,0 +1,74 @@
import { create } from "zustand";
interface ActiveTrack {
src: string;
title: string;
}
interface AudioPlayerState {
track: ActiveTrack | null;
audioEl: HTMLAudioElement | null;
isPlaying: boolean;
volume: number;
activate: (audioEl: HTMLAudioElement, track: ActiveTrack) => void;
detach: (audioEl: HTMLAudioElement) => void;
setPlaying: (audioEl: HTMLAudioElement, isPlaying: boolean) => void;
setVolume: (audioEl: HTMLAudioElement, volume: number) => void;
togglePlay: () => Promise<void>;
stop: () => void;
}
export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
track: null,
audioEl: null,
isPlaying: false,
volume: 1,
activate: (audioEl, track) => {
set({
audioEl,
track,
isPlaying: !audioEl.paused,
volume: audioEl.volume ?? 1,
});
},
detach: (audioEl) => {
const current = get().audioEl;
if (current !== audioEl) {
return;
}
set({ audioEl: null, track: null, isPlaying: false });
},
setPlaying: (audioEl, isPlaying) => {
if (get().audioEl !== audioEl) {
return;
}
set({ isPlaying });
},
setVolume: (audioEl, volume) => {
if (get().audioEl !== audioEl) {
return;
}
set({ volume });
},
togglePlay: async () => {
const audio = get().audioEl;
if (!audio) {
return;
}
if (audio.paused) {
await audio.play();
set({ isPlaying: true });
return;
}
audio.pause();
set({ isPlaying: false });
},
stop: () => {
const audio = get().audioEl;
if (audio) {
audio.pause();
}
set({ audioEl: null, track: null, isPlaying: false });
},
}));

View File

@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"} {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"}