feat(web): inline chat search and global audio bar
Some checks failed
CI / test (push) Failing after 20s
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:
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -58,19 +58,19 @@ body {
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--bm-bg-primary: #eef3fb;
|
||||
--bm-bg-secondary: #f5f8fd;
|
||||
--bm-bg-tertiary: #ffffff;
|
||||
--bm-bg-primary: #eaf1fb;
|
||||
--bm-bg-secondary: #f3f7fd;
|
||||
--bm-bg-tertiary: #fbfdff;
|
||||
--bm-text-color: #0f172a;
|
||||
--bm-panel-bg: rgba(255, 255, 255, 0.93);
|
||||
--bm-panel-border: rgba(15, 23, 42, 0.12);
|
||||
--bm-panel-bg: rgba(255, 255, 255, 0.96);
|
||||
--bm-panel-border: rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
html[data-theme="light"] .tg-chat-wallpaper {
|
||||
background:
|
||||
radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 86% 74%, rgba(34, 197, 94, 0.06), transparent 33%),
|
||||
linear-gradient(160deg, rgba(15, 23, 42, 0.01) 0%, rgba(15, 23, 42, 0.02) 100%);
|
||||
radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 32%),
|
||||
radial-gradient(circle at 86% 74%, rgba(14, 165, 233, 0.06), transparent 35%),
|
||||
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,
|
||||
@@ -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\/70,
|
||||
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\/70,
|
||||
html[data-theme="light"] .bg-slate-800\/60,
|
||||
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,
|
||||
@@ -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\/50,
|
||||
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-200,
|
||||
html[data-theme="light"] .text-slate-300,
|
||||
html[data-theme="light"] .text-slate-400 {
|
||||
color: #334155 !important;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
74
web/src/store/audioPlayerStore.ts
Normal file
74
web/src/store/audioPlayerStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user