feat(web): redesign full-screen media viewer UX
All checks were successful
CI / test (push) Successful in 27s
All checks were successful
CI / test (push) Successful in 27s
This commit is contained in:
@@ -19,6 +19,7 @@ import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSea
|
|||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { useUiStore } from "../store/uiStore";
|
import { useUiStore } from "../store/uiStore";
|
||||||
|
import { MediaViewer } from "./MediaViewer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatId: number | null;
|
chatId: number | null;
|
||||||
@@ -745,43 +746,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{mediaViewer ? (
|
{mediaViewer ? (
|
||||||
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
|
<MediaViewer
|
||||||
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
index={mediaViewer.index}
|
||||||
<button className="absolute left-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs" onClick={() => setMediaViewer(null)} type="button">
|
items={mediaViewer.items}
|
||||||
Close
|
onClose={() => setMediaViewer(null)}
|
||||||
</button>
|
onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
|
||||||
{mediaViewer.items.length > 1 ? (
|
onJumpToMessage={(messageId) => jumpToMessage(messageId)}
|
||||||
<>
|
onToast={showToast}
|
||||||
<button
|
open
|
||||||
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
|
/>
|
||||||
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
|
|
||||||
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="absolute right-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs"
|
|
||||||
onClick={() => jumpToMessage(mediaViewer.items[mediaViewer.index].messageId)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Jump
|
|
||||||
</button>
|
|
||||||
{mediaViewer.items[mediaViewer.index].type === "image" ? (
|
|
||||||
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
|
|
||||||
) : (
|
|
||||||
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
305
web/src/components/MediaViewer.tsx
Normal file
305
web/src/components/MediaViewer.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export type MediaViewerItem = {
|
||||||
|
url: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
messageId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MediaViewerProps {
|
||||||
|
open: boolean;
|
||||||
|
items: MediaViewerItem[];
|
||||||
|
index: number;
|
||||||
|
onIndexChange: (index: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onToast?: (message: string) => void;
|
||||||
|
onJumpToMessage?: (messageId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaViewer(props: MediaViewerProps) {
|
||||||
|
const { open, items, index, onIndexChange, onClose, onToast, onJumpToMessage } = props;
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragStartRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
|
||||||
|
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const current = useMemo(() => items[index] ?? null, [items, index]);
|
||||||
|
const canPrev = items.length > 1;
|
||||||
|
const canNext = items.length > 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
setDragging(false);
|
||||||
|
dragStartRef.current = null;
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
}, [index, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowLeft" && canPrev) {
|
||||||
|
onIndexChange(index <= 0 ? items.length - 1 : index - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowRight" && canNext) {
|
||||||
|
onIndexChange(index >= items.length - 1 ? 0 : index + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((event.key === "+" || event.key === "=") && current?.type === "image") {
|
||||||
|
setZoom((prev) => Math.min(5, prev + 0.25));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "-" && current?.type === "image") {
|
||||||
|
setZoom((prev) => Math.max(1, prev - 0.25));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "0" && current?.type === "image") {
|
||||||
|
setZoom(1);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [canNext, canPrev, current?.type, index, items.length, onClose, onIndexChange, open]);
|
||||||
|
|
||||||
|
if (!open || !current || !items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
try {
|
||||||
|
await downloadFileFromUrl(current.url);
|
||||||
|
onToast?.("File downloaded");
|
||||||
|
} catch {
|
||||||
|
onToast?.("Download failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyLink() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(current.url);
|
||||||
|
onToast?.("Link copied");
|
||||||
|
} catch {
|
||||||
|
onToast?.("Failed to copy link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(event: WheelEvent<HTMLDivElement>) {
|
||||||
|
if (current.type !== "image") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.deltaY > 0 ? -0.15 : 0.15;
|
||||||
|
setZoom((prev) => {
|
||||||
|
const next = Math.min(5, Math.max(1, prev + delta));
|
||||||
|
if (next === 1) {
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
if (current.type !== "image") return;
|
||||||
|
swipeStartRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
if (zoom <= 1) return;
|
||||||
|
setDragging(true);
|
||||||
|
dragStartRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!dragging || !dragStartRef.current || zoom <= 1) return;
|
||||||
|
const dx = event.clientX - dragStartRef.current.x;
|
||||||
|
const dy = event.clientY - dragStartRef.current.y;
|
||||||
|
setOffset({ x: dragStartRef.current.ox + dx, y: dragStartRef.current.oy + dy });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
const swipeStart = swipeStartRef.current;
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
if (dragging) {
|
||||||
|
setDragging(false);
|
||||||
|
dragStartRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!swipeStart || items.length < 2 || zoom > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = event.clientX - swipeStart.x;
|
||||||
|
const dy = event.clientY - swipeStart.y;
|
||||||
|
if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
if (dx < 0) {
|
||||||
|
onIndexChange(index >= items.length - 1 ? 0 : index + 1);
|
||||||
|
} else {
|
||||||
|
onIndexChange(index <= 0 ? items.length - 1 : index - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[180] bg-black/80 backdrop-blur-md" onClick={onClose}>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-2 md:p-6" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="relative flex h-full w-full max-w-7xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-slate-950/70 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-white/10 bg-slate-900/80 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(current.url)}</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{index + 1} / {items.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{current.messageId && onJumpToMessage ? (
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
|
||||||
|
onClick={() => onJumpToMessage(current.messageId!)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Jump
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
|
||||||
|
onClick={() => window.open(current.url, "_blank", "noopener,noreferrer")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
|
||||||
|
onClick={() => void handleCopyLink()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
|
||||||
|
onClick={() => void handleDownload()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-100 transition hover:bg-white/10"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black/30"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
setDragging(false);
|
||||||
|
dragStartRef.current = null;
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (current.type !== "image") return;
|
||||||
|
if (zoom > 1) {
|
||||||
|
setZoom(1);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
} else {
|
||||||
|
setZoom(2);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canPrev ? (
|
||||||
|
<button
|
||||||
|
className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full border border-white/20 bg-black/45 px-3 py-2 text-xl text-white transition hover:bg-black/70"
|
||||||
|
onClick={() => onIndexChange(index <= 0 ? items.length - 1 : index - 1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canNext ? (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full border border-white/20 bg-black/45 px-3 py-2 text-xl text-white transition hover:bg-black/70"
|
||||||
|
onClick={() => onIndexChange(index >= items.length - 1 ? 0 : index + 1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{current.type === "image" ? (
|
||||||
|
<img
|
||||||
|
alt="media"
|
||||||
|
className="max-h-full max-w-full select-none object-contain transition-transform duration-150"
|
||||||
|
draggable={false}
|
||||||
|
src={current.url}
|
||||||
|
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`, cursor: zoom > 1 ? "grab" : "zoom-in" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video className="max-h-full max-w-full rounded-xl" controls src={current.url} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<div className="border-t border-white/10 bg-slate-900/80 px-2 py-2">
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{items.map((item, itemIndex) => (
|
||||||
|
<button
|
||||||
|
className={`relative h-14 w-20 shrink-0 overflow-hidden rounded-md border ${
|
||||||
|
itemIndex === index ? "border-sky-400" : "border-white/10"
|
||||||
|
}`}
|
||||||
|
key={`${item.url}-${itemIndex}`}
|
||||||
|
onClick={() => onIndexChange(itemIndex)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.type === "image" ? (
|
||||||
|
<img alt="" className="h-full w-full object-cover" draggable={false} src={item.url} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-slate-800 text-xs text-slate-200">VIDEO</div>
|
||||||
|
)}
|
||||||
|
{itemIndex === index ? <span className="absolute inset-0 ring-1 ring-sky-300/70" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFileName(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const value = parsed.pathname.split("/").pop();
|
||||||
|
return decodeURIComponent(value || "file");
|
||||||
|
} catch {
|
||||||
|
const value = url.split("/").pop();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { useAudioPlayerStore } from "../store/audioPlayerStore";
|
|||||||
import { useUiStore } from "../store/uiStore";
|
import { useUiStore } from "../store/uiStore";
|
||||||
import { formatTime } from "../utils/format";
|
import { formatTime } from "../utils/format";
|
||||||
import { formatMessageHtml } from "../utils/formatMessage";
|
import { formatMessageHtml } from "../utils/formatMessage";
|
||||||
|
import { MediaViewer } from "./MediaViewer";
|
||||||
|
|
||||||
type ContextMenuState = {
|
type ContextMenuState = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -724,72 +725,14 @@ export function MessageList() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mediaViewer ? (
|
{mediaViewer ? (
|
||||||
<div className="fixed inset-0 z-[170] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
|
<MediaViewer
|
||||||
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
index={mediaViewer.index}
|
||||||
<button
|
items={mediaViewer.items}
|
||||||
className="absolute left-1 top-1 z-10 rounded-full bg-slate-900/80 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800 md:left-3 md:top-3"
|
onClose={() => setMediaViewer(null)}
|
||||||
onClick={() => setMediaViewer(null)}
|
onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
|
||||||
type="button"
|
onToast={showToast}
|
||||||
>
|
open
|
||||||
Close
|
/>
|
||||||
</button>
|
|
||||||
|
|
||||||
{mediaViewer.items.length > 1 ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded-full bg-slate-900/80 px-2 py-1 text-xs text-slate-200 md:top-4"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{mediaViewer.index + 1} / {mediaViewer.items.length}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="absolute left-1 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-3 text-lg text-slate-200 hover:bg-slate-800 md:left-3"
|
|
||||||
onClick={() =>
|
|
||||||
setMediaViewer((prev) =>
|
|
||||||
prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="absolute right-1 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-3 text-lg text-slate-200 hover:bg-slate-800 md:right-3"
|
|
||||||
onClick={() =>
|
|
||||||
setMediaViewer((prev) =>
|
|
||||||
prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="absolute right-1 top-1 z-10 rounded-full bg-slate-900/80 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800 md:right-3 md:top-3"
|
|
||||||
onClick={async () => {
|
|
||||||
const current = mediaViewer.items[mediaViewer.index];
|
|
||||||
try {
|
|
||||||
await downloadFileFromUrl(current.url);
|
|
||||||
showToast("File downloaded");
|
|
||||||
} catch {
|
|
||||||
showToast("Download failed");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{mediaViewer.items[mediaViewer.index]?.type === "image" ? (
|
|
||||||
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
|
|
||||||
) : (
|
|
||||||
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{deleteMessageId ? (
|
{deleteMessageId ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user