feat(web): refresh audio card UI and enforce outside-click menu close
All checks were successful
CI / test (push) Successful in 24s

This commit is contained in:
2026-03-08 12:55:55 +03:00
parent 82322c4d42
commit 58208787e7
2 changed files with 117 additions and 83 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { globalSearch } from "../api/search"; import { globalSearch } from "../api/search";
@@ -46,6 +46,9 @@ export function ChatList() {
const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true); const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true);
const [profileError, setProfileError] = useState<string | null>(null); const [profileError, setProfileError] = useState<string | null>(null);
const [profileSaving, setProfileSaving] = useState(false); const [profileSaving, setProfileSaving] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null);
const burgerMenuRef = useRef<HTMLDivElement | null>(null);
const burgerButtonRef = useRef<HTMLButtonElement | null>(null);
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null; const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
const canDeleteForEveryone = Boolean( const canDeleteForEveryone = Boolean(
deleteModalChat && deleteModalChat &&
@@ -168,6 +171,25 @@ export function ChatList() {
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, []); }, []);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (ctxChatId) {
setCtxChatId(null);
setCtxPos(null);
}
if (menuOpen) {
const inMenu = burgerMenuRef.current?.contains(target ?? null) ?? false;
const inButton = burgerButtonRef.current?.contains(target ?? null) ?? false;
if (!inMenu && !inButton) {
setMenuOpen(false);
}
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [ctxChatId, menuOpen]);
useEffect(() => { useEffect(() => {
applyAppearancePreferences(getAppPreferences()); applyAppearancePreferences(getAppPreferences());
}, []); }, []);
@@ -271,10 +293,11 @@ export function ChatList() {
} }
return ( return (
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); setMenuOpen(false); }}> <aside ref={sidebarRef} className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); setMenuOpen(false); }}>
<div className="border-b border-slate-700/50 px-3 py-3"> <div className="border-b border-slate-700/50 px-3 py-3">
<div className="relative mb-2 flex items-center gap-2"> <div className="relative mb-2 flex items-center gap-2">
<button <button
ref={burgerButtonRef}
className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs" className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -285,6 +308,7 @@ export function ChatList() {
</button> </button>
{menuOpen ? ( {menuOpen ? (
<div <div
ref={burgerMenuRef}
className="absolute left-0 top-11 z-40 w-56 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" className="absolute left-0 top-11 z-40 w-56 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -597,68 +621,70 @@ export function ChatList() {
{ctxChatId && ctxPos {ctxChatId && ctxPos
? createPortal( ? createPortal(
<div className="fixed z-[100] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}> <div className="fixed inset-0 z-[99]" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
<button <div className="fixed z-[100] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800" <button
onClick={() => { className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
setDeleteModalChatId(ctxChatId); onClick={() => {
setCtxChatId(null); setDeleteModalChatId(ctxChatId);
setCtxPos(null); setCtxChatId(null);
setDeleteForAll(false); setCtxPos(null);
}} setDeleteForAll(false);
> }}
{(() => { >
const chat = chats.find((c) => c.id === ctxChatId); {(() => {
if (!chat) return "Delete chat"; const chat = chats.find((c) => c.id === ctxChatId);
if (chat.is_saved) return "Clear chat"; if (!chat) return "Delete chat";
if (chat.type === "channel" && chat.my_role === "member") return "Leave channel"; if (chat.is_saved) return "Clear chat";
return "Delete chat"; if (chat.type === "channel" && chat.my_role === "member") return "Leave channel";
})()} return "Delete chat";
</button> })()}
<button </button>
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" <button
onClick={async () => { className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId); onClick={async () => {
if (!target) { const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
return; if (!target) {
} return;
if (target.archived) { }
await unarchiveChat(target.id); if (target.archived) {
} else { await unarchiveChat(target.id);
await archiveChat(target.id); } else {
} await archiveChat(target.id);
await loadChats(); }
const nextArchived = await getChats(undefined, true); await loadChats();
setArchivedChats(nextArchived);
setCtxChatId(null);
setCtxPos(null);
}}
>
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
if (!target) {
return;
}
if (target.pinned) {
await unpinChat(target.id);
} else {
await pinChat(target.id);
}
await loadChats();
if (tab === "archived") {
const nextArchived = await getChats(undefined, true); const nextArchived = await getChats(undefined, true);
setArchivedChats(nextArchived); setArchivedChats(nextArchived);
} setCtxChatId(null);
setCtxChatId(null); setCtxPos(null);
setCtxPos(null); }}
}} >
> {(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.pinned ? "Unpin chat" : "Pin chat"} </button>
</button> <button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
if (!target) {
return;
}
if (target.pinned) {
await unpinChat(target.id);
} else {
await pinChat(target.id);
}
await loadChats();
if (tab === "archived") {
const nextArchived = await getChats(undefined, true);
setArchivedChats(nextArchived);
}
setCtxChatId(null);
setCtxPos(null);
}}
>
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.pinned ? "Unpin chat" : "Pin chat"}
</button>
</div>
</div>, </div>,
document.body document.body
) )

View File

@@ -511,11 +511,12 @@ export function MessageList() {
{ctx {ctx
? createPortal( ? createPortal(
<div <div className="fixed inset-0 z-[109]" onClick={() => setCtx(null)}>
className="fixed z-[110] w-56 rounded-xl border border-slate-700/90 bg-slate-900/95 p-1.5 shadow-2xl" <div
style={{ left: ctx.x, top: ctx.y }} className="fixed z-[110] w-56 rounded-xl border border-slate-700/90 bg-slate-900/95 p-1.5 shadow-2xl"
onClick={(event) => event.stopPropagation()} style={{ left: ctx.x, top: ctx.y }}
> onClick={(event) => event.stopPropagation()}
>
<div className="mb-1 flex flex-wrap gap-1 rounded-lg bg-slate-800/80 p-1"> <div className="mb-1 flex flex-wrap gap-1 rounded-lg bg-slate-800/80 p-1">
{QUICK_REACTIONS.map((emoji) => ( {QUICK_REACTIONS.map((emoji) => (
<button <button
@@ -623,6 +624,7 @@ export function MessageList() {
</button> </button>
</> </>
) : null} ) : null}
</div>
</div>, </div>,
document.body document.body
) )
@@ -854,10 +856,10 @@ function renderMessageContent(
opts.onAttachmentContextMenu(event, text); opts.onAttachmentContextMenu(event, text);
}}> }}>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300"> <div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎵</span> <span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/25 text-emerald-200">🎵</span>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-semibold text-slate-200">{extractFileName(text)}</p> <p className="truncate font-semibold text-slate-100">{extractFileName(text)}</p>
<p className="text-[11px] text-slate-400">Audio file</p> <p className="text-[11px] text-slate-400">Audio</p>
</div> </div>
</div> </div>
<AudioInlinePlayer src={text} title={extractFileName(text)} /> <AudioInlinePlayer src={text} title={extractFileName(text)} />
@@ -994,28 +996,34 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
} }
return ( return (
<div className="rounded-lg border border-sky-500/40 bg-sky-600/20 px-2 py-1.5"> <div className="rounded-lg border border-slate-600/70 bg-slate-900/60 px-2 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/70 text-xs text-white hover:bg-slate-900" className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/80 text-xs text-slate-950 hover:bg-emerald-400"
onClick={() => void togglePlay()} onClick={() => void togglePlay()}
type="button" type="button"
> >
{isPlaying ? "❚❚" : "▶"} {isPlaying ? "❚❚" : "▶"}
</button> </button>
<input <div className="min-w-0 flex-1">
className="h-1.5 w-24 cursor-pointer accent-sky-300" <div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-slate-300">
max={Math.max(duration, 0.01)} <span className="truncate">{title}</span>
min={0} <span className="tabular-nums">{formatAudioTime(position)} / {formatAudioTime(duration)}</span>
onChange={(event) => onSeek(Number(event.target.value))} </div>
step={0.1} <input
type="range" className="h-1.5 w-full cursor-pointer accent-emerald-300"
value={Math.min(position, Math.max(duration, 0.01))} max={Math.max(duration, 0.01)}
/> min={0}
onChange={(event) => onSeek(Number(event.target.value))}
step={0.1}
type="range"
value={Math.min(position, Math.max(duration, 0.01))}
/>
</div>
<span className="w-20 text-center text-xs tabular-nums text-slate-100"> <span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-800 px-1 text-[10px] text-slate-300">
{formatAudioTime(position)} / {formatAudioTime(duration)}
</span> </span>
</div> </div>
</div> </div>