fix(web): chat sidebar layout, media context actions, and scrollable chat info
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
This commit is contained in:
@@ -45,12 +45,21 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||||
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
|
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
|
||||||
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string } | null>(null);
|
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 myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
|
||||||
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
||||||
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
||||||
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
|
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
|
||||||
const canChangeRoles = Boolean(isGroupLike && myRole === "owner");
|
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) {
|
async function refreshMembers(targetChatId: number) {
|
||||||
const nextMembers = await listChatMembers(targetChatId);
|
const nextMembers = await listChatMembers(targetChatId);
|
||||||
@@ -139,12 +148,13 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
onClose();
|
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()}>
|
<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="mb-3 flex items-center justify-between">
|
<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>
|
<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>
|
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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}
|
{loading ? <p className="text-sm text-slate-300">Loading...</p> : null}
|
||||||
{error ? <p className="text-sm text-red-400">{error}</p> : null}
|
{error ? <p className="text-sm text-red-400">{error}</p> : null}
|
||||||
|
|
||||||
@@ -343,19 +353,32 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
|
||||||
Media & Files {attachments.length > 0 ? `(${attachments.length})` : ""}
|
Media & Files {attachments.length > 0 ? `(${attachments.length})` : ""}
|
||||||
</p>
|
</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 ? <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 && 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">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{attachments
|
{mediaAttachments.slice(0, 90).map((item) => (
|
||||||
.filter((item) => item.file_type.startsWith("image/"))
|
|
||||||
.slice(0, 9)
|
|
||||||
.map((item) => (
|
|
||||||
<button
|
<button
|
||||||
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
|
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
|
||||||
key={`media-image-${item.id}`}
|
key={`media-item-${item.id}`}
|
||||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -367,15 +390,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
{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} />
|
<img alt="attachment" className="h-full w-full object-cover transition group-hover:scale-105" src={item.file_url} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="tg-scrollbar max-h-44 space-y-1 overflow-auto">
|
</>
|
||||||
{attachments.slice(0, 40).map((item) => (
|
) : null}
|
||||||
|
{!attachmentsLoading && attachmentsTab === "files" ? (
|
||||||
|
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
|
||||||
|
{fileAttachments.slice(0, 100).map((item) => (
|
||||||
<button
|
<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="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={`media-item-${item.id}`}
|
key={`file-item-${item.id}`}
|
||||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -394,7 +424,6 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -442,6 +471,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{attachmentCtx ? (
|
{attachmentCtx ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function ChatList() {
|
|||||||
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
|
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
|
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
|
||||||
<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="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs">☰</button>
|
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs">☰</button>
|
||||||
|
|||||||
@@ -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 { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
@@ -17,12 +17,7 @@ type ContextMenuState = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
} | null;
|
attachmentUrl?: string | null;
|
||||||
|
|
||||||
type AttachmentMenuState = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
url: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type PendingDeleteState = {
|
type PendingDeleteState = {
|
||||||
@@ -52,7 +47,6 @@ export function MessageList() {
|
|||||||
const restoreMessages = useChatStore((s) => s.restoreMessages);
|
const restoreMessages = useChatStore((s) => s.restoreMessages);
|
||||||
|
|
||||||
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
||||||
const [attachmentCtx, setAttachmentCtx] = useState<AttachmentMenuState>(null);
|
|
||||||
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
||||||
const [forwardQuery, setForwardQuery] = useState("");
|
const [forwardQuery, setForwardQuery] = useState("");
|
||||||
const [forwardError, setForwardError] = useState<string | null>(null);
|
const [forwardError, setForwardError] = useState<string | null>(null);
|
||||||
@@ -64,6 +58,7 @@ export function MessageList() {
|
|||||||
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
||||||
const [undoTick, setUndoTick] = useState(0);
|
const [undoTick, setUndoTick] = useState(0);
|
||||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -100,7 +95,6 @@ export function MessageList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCtx(null);
|
setCtx(null);
|
||||||
setAttachmentCtx(null);
|
|
||||||
setForwardMessageId(null);
|
setForwardMessageId(null);
|
||||||
setForwardSelectedChatIds(new Set());
|
setForwardSelectedChatIds(new Set());
|
||||||
setDeleteMessageId(null);
|
setDeleteMessageId(null);
|
||||||
@@ -113,13 +107,20 @@ export function MessageList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setCtx(null);
|
setCtx(null);
|
||||||
setAttachmentCtx(null);
|
|
||||||
setDeleteMessageId(null);
|
setDeleteMessageId(null);
|
||||||
setForwardMessageId(null);
|
setForwardMessageId(null);
|
||||||
setForwardSelectedChatIds(new Set());
|
setForwardSelectedChatIds(new Set());
|
||||||
setReactionsByMessage({});
|
setReactionsByMessage({});
|
||||||
}, [activeChatId]);
|
}, [activeChatId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setToast(null), 2200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingDelete) {
|
if (!pendingDelete) {
|
||||||
return;
|
return;
|
||||||
@@ -298,7 +299,7 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 ? (
|
{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">
|
<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"}
|
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
|
||||||
@@ -371,7 +372,7 @@ export function MessageList() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void ensureReactionsLoaded(message.id);
|
void ensureReactionsLoaded(message.id);
|
||||||
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 220);
|
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 ? (
|
{selectedIds.size > 0 ? (
|
||||||
@@ -412,8 +413,9 @@ export function MessageList() {
|
|||||||
{renderMessageContent(message.type, message.text, {
|
{renderMessageContent(message.type, message.text, {
|
||||||
onAttachmentContextMenu: (event, url) => {
|
onAttachmentContextMenu: (event, url) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const pos = getSafeContextPosition(event.clientX, event.clientY, 180, 110);
|
void ensureReactionsLoaded(message.id);
|
||||||
setAttachmentCtx({ x: pos.x, y: pos.y, url });
|
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)}>
|
<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
|
Pin / Unpin
|
||||||
</button>
|
</button>
|
||||||
</div>,
|
{ctx.attachmentUrl ? (
|
||||||
document.body
|
<>
|
||||||
)
|
<div className="my-1 h-px bg-slate-700/80" />
|
||||||
: null}
|
<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
|
||||||
{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>
|
</a>
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
const url = ctx.attachmentUrl;
|
||||||
await navigator.clipboard.writeText(attachmentCtx.url);
|
if (!url) {
|
||||||
} catch {
|
|
||||||
return;
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
Copy link
|
Copy link
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
@@ -638,6 +657,11 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</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="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
|
||||||
<span className="font-semibold">Voice message</span>
|
<span className="font-semibold">Voice message</span>
|
||||||
</div>
|
</div>
|
||||||
<audio className="w-full" controls preload="metadata" src={text} />
|
<AudioInlinePlayer src={text} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,7 +711,7 @@ function renderMessageContent(
|
|||||||
<p className="text-[11px] text-slate-400">Audio file</p>
|
<p className="text-[11px] text-slate-400">Audio file</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<audio className="w-full" controls preload="metadata" src={text} />
|
<AudioInlinePlayer src={text} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -756,3 +780,130 @@ function extractFileName(url: string): string {
|
|||||||
return value ? decodeURIComponent(value) : "file";
|
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")}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ export function NewChatPanel() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute bottom-4 right-4 z-20">
|
<div className="absolute bottom-4 right-4 z-20">
|
||||||
|
<div className="relative">
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
<div className="mb-2 w-48 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
|
<div className="absolute bottom-14 right-0 w-48 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
|
||||||
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => void openSavedMessages()}>
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => void openSavedMessages()}>
|
||||||
Saved Messages
|
Saved Messages
|
||||||
</button>
|
</button>
|
||||||
@@ -140,6 +141,7 @@ export function NewChatPanel() {
|
|||||||
<span className="block w-5 text-center text-2xl leading-none">{menuOpen ? "✕" : "+"}</span>
|
<span className="block w-5 text-center text-2xl leading-none">{menuOpen ? "✕" : "+"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{dialog !== "none" ? (
|
{dialog !== "none" ? (
|
||||||
<div className="absolute inset-0 z-30 flex items-end justify-center bg-slate-950/55 p-3">
|
<div className="absolute inset-0 z-30 flex items-end justify-center bg-slate-950/55 p-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user