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 [members, setMembers] = useState<ChatMember[]>([]);
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
const [counterpartProfile, setCounterpartProfile] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
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) {
try {
const counterpart = await getUserById(detail.counterpart_user_id);
if (!cancelled) {
setCounterpartProfile(counterpart);
}
const blocked = await listBlockedUsers();
if (!cancelled) {
setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id));
}
} catch {
if (!cancelled) {
setCounterpartProfile(null);
setCounterpartBlocked(false);
}
}
} else if (!cancelled) {
setCounterpartProfile(null);
setCounterpartBlocked(false);
}
await refreshMembers(chatId);
@@ -204,6 +211,32 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
{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-2 flex items-center justify-between">
<p className="text-xs text-slate-400">Notifications</p>
@@ -226,37 +259,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</button>
</div>
<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 ? (
<button
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={savingTitle || !titleDraft.trim()}
onClick={async () => {
setSavingTitle(true);
try {
const updated = await updateChatTitle(chatId, titleDraft.trim());
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
} catch {
setError("Failed to update title");
} finally {
setSavingTitle(false);
}
}}
>
Save title
</button>
<>
<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"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
/>
<button
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={savingTitle || !titleDraft.trim()}
onClick={async () => {
setSavingTitle(true);
try {
const updated = await updateChatTitle(chatId, titleDraft.trim());
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
} catch {
setError("Failed to update title");
} finally {
setSavingTitle(false);
}
}}
>
Save title
</button>
</>
) : 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 ? (
<div className="mt-2">
<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 {
if (size < 1024) return `${size} B`;
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>
{messageResults.slice(0, 5).map((message) => (
<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}`}
onClick={async () => {
setActiveChatId(message.chat_id);
@@ -300,7 +300,7 @@ export function ChatList() {
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>
</button>
))}
@@ -539,6 +539,17 @@ function chatLabel(chat: { display_title?: string | null; title: string | null;
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: {
type: "private" | "group" | "channel";
is_saved?: boolean;

View File

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