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

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import {
deleteMessage,
@@ -17,12 +17,7 @@ type ContextMenuState = {
x: number;
y: number;
messageId: number;
} | null;
type AttachmentMenuState = {
x: number;
y: number;
url: string;
attachmentUrl?: string | null;
} | null;
type PendingDeleteState = {
@@ -52,7 +47,6 @@ export function MessageList() {
const restoreMessages = useChatStore((s) => s.restoreMessages);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const [attachmentCtx, setAttachmentCtx] = useState<AttachmentMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null);
@@ -64,6 +58,7 @@ export function MessageList() {
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
const [toast, setToast] = useState<string | null>(null);
const messages = useMemo(() => {
if (!activeChatId) {
@@ -100,7 +95,6 @@ export function MessageList() {
return;
}
setCtx(null);
setAttachmentCtx(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setDeleteMessageId(null);
@@ -113,13 +107,20 @@ export function MessageList() {
useEffect(() => {
setSelectedIds(new Set());
setCtx(null);
setAttachmentCtx(null);
setDeleteMessageId(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setReactionsByMessage({});
}, [activeChatId]);
useEffect(() => {
if (!toast) {
return;
}
const timer = window.setTimeout(() => setToast(null), 2200);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!pendingDelete) {
return;
@@ -298,7 +299,7 @@ export function MessageList() {
}
return (
<div className="flex h-full flex-col" onClick={() => { setCtx(null); setAttachmentCtx(null); }}>
<div className="flex h-full flex-col" onClick={() => { setCtx(null); }}>
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
@@ -371,7 +372,7 @@ export function MessageList() {
event.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 220);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: null });
}}
>
{selectedIds.size > 0 ? (
@@ -412,8 +413,9 @@ export function MessageList() {
{renderMessageContent(message.type, message.text, {
onAttachmentContextMenu: (event, url) => {
event.preventDefault();
const pos = getSafeContextPosition(event.clientX, event.clientY, 180, 110);
setAttachmentCtx({ x: pos.x, y: pos.y, url });
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 280);
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: url });
}
})}
@@ -517,37 +519,54 @@ export function MessageList() {
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>,
document.body
)
: null}
{attachmentCtx
? createPortal(
<div
className="fixed z-[111] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: attachmentCtx.x, top: attachmentCtx.y }}
onClick={(event) => event.stopPropagation()}
>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
Open
</a>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
Download
</a>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await navigator.clipboard.writeText(attachmentCtx.url);
} catch {
return;
}
}}
type="button"
>
Copy link
</button>
{ctx.attachmentUrl ? (
<>
<div className="my-1 h-px bg-slate-700/80" />
<a className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" href={ctx.attachmentUrl} rel="noreferrer" target="_blank">
Open media
</a>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const url = ctx.attachmentUrl;
if (!url) {
return;
}
try {
await downloadFileFromUrl(url);
setToast("File downloaded");
} catch {
setToast("Download failed");
} finally {
setCtx(null);
}
}}
type="button"
>
Download
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const url = ctx.attachmentUrl;
if (!url) {
return;
}
try {
await navigator.clipboard.writeText(url);
setToast("Link copied");
} catch {
setToast("Copy failed");
} finally {
setCtx(null);
}
}}
type="button"
>
Copy link
</button>
</>
) : null}
</div>,
document.body
)
@@ -638,6 +657,11 @@ export function MessageList() {
</div>
</div>
) : null}
{toast ? (
<div className="pointer-events-none absolute bottom-3 left-0 right-0 z-[120] flex justify-center px-3">
<div className="rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">{toast}</div>
</div>
) : null}
</div>
);
}
@@ -672,7 +696,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>
<audio className="w-full" controls preload="metadata" src={text} />
<AudioInlinePlayer src={text} />
</div>
);
}
@@ -687,7 +711,7 @@ function renderMessageContent(
<p className="text-[11px] text-slate-400">Audio file</p>
</div>
</div>
<audio className="w-full" controls preload="metadata" src={text} />
<AudioInlinePlayer src={text} />
</div>
);
}
@@ -756,3 +780,130 @@ function extractFileName(url: string): string {
return value ? decodeURIComponent(value) : "file";
}
}
async function downloadFileFromUrl(url: string): Promise<void> {
const response = await fetch(url, { mode: "cors" });
if (!response.ok) {
throw new Error("Download failed");
}
const blob = await response.blob();
const filename = extractFileName(url);
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(blobUrl);
}
function AudioInlinePlayer({ src }: { src: string }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [position, setPosition] = useState(0);
const [volume, setVolume] = useState(1);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onLoaded = () => {
setDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
};
const onTime = () => {
setPosition(audio.currentTime || 0);
};
const onEnded = () => {
setIsPlaying(false);
};
audio.addEventListener("loadedmetadata", onLoaded);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnded);
return () => {
audio.removeEventListener("loadedmetadata", onLoaded);
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnded);
};
}, []);
async function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
return;
}
try {
await audio.play();
setIsPlaying(true);
} catch {
setIsPlaying(false);
}
}
function onSeek(nextValue: number) {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = nextValue;
setPosition(nextValue);
}
function onVolume(nextValue: number) {
const audio = audioRef.current;
if (!audio) return;
audio.volume = nextValue;
setVolume(nextValue);
}
return (
<div className="rounded-lg border border-sky-500/40 bg-sky-600/20 px-2 py-1.5">
<audio ref={audioRef} preload="metadata" src={src} />
<div className="flex items-center gap-2">
<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"
onClick={() => void togglePlay()}
type="button"
>
{isPlaying ? "❚❚" : "▶"}
</button>
<input
className="h-1.5 w-24 cursor-pointer accent-sky-300"
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))}
/>
<span className="w-20 text-center text-xs tabular-nums text-slate-100">
{formatAudioTime(position)} / {formatAudioTime(duration)}
</span>
<span className="text-xs text-slate-200">🔊</span>
<input
className="h-1.5 w-16 cursor-pointer accent-slate-100"
max={1}
min={0}
onChange={(event) => onVolume(Number(event.target.value))}
step={0.05}
type="range"
value={volume}
/>
</div>
</div>
);
}
function formatAudioTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const rem = total % 60;
return `${minutes}:${String(rem).padStart(2, "0")}`;
}