fix(web): chat sidebar layout, media context actions, and scrollable chat info
Some checks failed
CI / test (push) Failing after 21s

This commit is contained in:
2026-03-08 10:30:38 +03:00
parent 6a96a99775
commit 1119cc65b8
4 changed files with 271 additions and 88 deletions

View File

@@ -45,12 +45,21 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string } | null>(null);
const [attachmentsTab, setAttachmentsTab] = useState<"media" | "files">("media");
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
const canChangeRoles = Boolean(isGroupLike && myRole === "owner");
const mediaAttachments = useMemo(
() => attachments.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/")),
[attachments]
);
const fileAttachments = useMemo(
() => attachments.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/")),
[attachments]
);
async function refreshMembers(targetChatId: number) {
const nextMembers = await listChatMembers(targetChatId);
@@ -139,17 +148,18 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
onClose();
}}
>
<aside className="absolute right-0 top-0 h-full w-full max-w-sm border-l border-slate-700/70 bg-slate-900/95 p-4 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between">
<aside className="absolute right-0 top-0 flex h-full w-full max-w-sm flex-col border-l border-slate-700/70 bg-slate-900/95 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
<p className="text-sm font-semibold">Chat info</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>Close</button>
</div>
{loading ? <p className="text-sm text-slate-300">Loading...</p> : null}
{error ? <p className="text-sm text-red-400">{error}</p> : null}
<div className="tg-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-3">
{loading ? <p className="text-sm text-slate-300">Loading...</p> : null}
{error ? <p className="text-sm text-red-400">{error}</p> : null}
{chat ? (
<>
{chat ? (
<>
<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>
@@ -343,38 +353,31 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
Media & Files {attachments.length > 0 ? `(${attachments.length})` : ""}
</p>
<div className="mb-2 flex items-center gap-2 border-b border-slate-700/60 pb-2 text-xs">
<button
className={`rounded px-2 py-1 ${attachmentsTab === "media" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("media")}
type="button"
>
Media
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "files" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("files")}
type="button"
>
Files
</button>
</div>
{attachmentsLoading ? <p className="text-xs text-slate-400">Loading attachments...</p> : null}
{!attachmentsLoading && attachments.length === 0 ? <p className="text-xs text-slate-400">No attachments yet</p> : null}
{!attachmentsLoading ? (
{!attachmentsLoading && attachmentsTab === "media" ? (
<>
<div className="mb-2 grid grid-cols-3 gap-1">
{attachments
.filter((item) => item.file_type.startsWith("image/"))
.slice(0, 9)
.map((item) => (
<button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
key={`media-image-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url
});
}}
type="button"
>
<img alt="attachment" className="h-full w-full object-cover transition group-hover:scale-105" src={item.file_url} />
</button>
))}
</div>
<div className="tg-scrollbar max-h-44 space-y-1 overflow-auto">
{attachments.slice(0, 40).map((item) => (
<div className="grid grid-cols-3 gap-1">
{mediaAttachments.slice(0, 90).map((item) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
key={`media-item-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onContextMenu={(event) => {
@@ -387,15 +390,41 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
}}
type="button"
>
<p className="truncate text-xs font-semibold text-slate-200">{extractFileName(item.file_url)}</p>
<p className="text-[11px] text-slate-400">
{attachmentKind(item.file_type)} {formatBytes(item.file_size)}
</p>
{item.file_type.startsWith("video/") ? (
<video className="h-full w-full object-cover transition group-hover:scale-105" muted src={item.file_url} />
) : (
<img alt="attachment" className="h-full w-full object-cover transition group-hover:scale-105" src={item.file_url} />
)}
</button>
))}
</div>
</>
) : null}
{!attachmentsLoading && attachmentsTab === "files" ? (
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
{fileAttachments.slice(0, 100).map((item) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
key={`file-item-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url
});
}}
type="button"
>
<p className="truncate text-xs font-semibold text-slate-200">{extractFileName(item.file_url)}</p>
<p className="text-[11px] text-slate-400">
{attachmentKind(item.file_type)} {formatBytes(item.file_size)}
</p>
</button>
))}
</div>
) : null}
</div>
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
@@ -440,8 +469,9 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
Leave chat
</button>
) : null}
</>
) : null}
</>
) : null}
</div>
</aside>
{attachmentCtx ? (
<div