Files
Messenger/web/src/components/MessageList.tsx
2026-03-08 10:40:57 +03:00

1027 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import {
deleteMessage,
forwardMessageBulk,
listMessageReactions,
pinMessage,
toggleMessageReaction
} from "../api/chats";
import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
type ContextMenuState = {
x: number;
y: number;
messageId: number;
attachmentUrl?: string | null;
} | null;
type PendingDeleteState = {
chatId: number;
messages: Message[];
expiresAt: number;
timerId: number;
} | null;
type MediaViewerState = {
items: Array<{ url: string; type: "image" | "video" }>;
index: number;
} | null;
const QUICK_REACTIONS = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const typingByChat = useChatStore((s) => s.typingByChat);
const hasMoreByChat = useChatStore((s) => s.hasMoreByChat);
const loadingMoreByChat = useChatStore((s) => s.loadingMoreByChat);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const unreadBoundaryByChat = useChatStore((s) => s.unreadBoundaryByChat);
const focusedMessageIdByChat = useChatStore((s) => s.focusedMessageIdByChat);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages);
const showToast = useUiStore((s) => s.showToast);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null);
const [isForwarding, setIsForwarding] = useState(false);
const [forwardSelectedChatIds, setForwardSelectedChatIds] = useState<Set<number>>(new Set());
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
const messages = useMemo(() => {
if (!activeChatId) {
return [];
}
return messagesByChat[activeChatId] ?? [];
}, [activeChatId, messagesByChat]);
const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const activeChat = chats.find((chat) => chat.id === activeChatId);
const forwardTargets = useMemo(() => {
const q = forwardQuery.trim().toLowerCase();
if (!q) return chats;
return chats.filter((chat) => {
const label = chatLabel(chat).toLowerCase();
const handle = (chat.handle || "").toLowerCase();
return label.includes(q) || handle.includes(q);
});
}, [chats, forwardQuery]);
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1;
const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null;
const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]);
const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]);
const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]);
const canDeleteAllForSelection = useMemo(
() => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)),
[selectedMessages, activeChat, me?.id]
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
setCtx(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setDeleteMessageId(null);
setSelectedIds(new Set());
setMediaViewer(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
setSelectedIds(new Set());
setCtx(null);
setDeleteMessageId(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setReactionsByMessage({});
}, [activeChatId]);
useEffect(() => {
if (!pendingDelete) {
return;
}
const interval = window.setInterval(() => setUndoTick((v) => v + 1), 250);
return () => window.clearInterval(interval);
}, [pendingDelete]);
useEffect(() => {
if (!activeChatId || !focusedMessageId) {
return;
}
const element = document.getElementById(`message-${focusedMessageId}`);
if (!element) {
return;
}
element.scrollIntoView({ behavior: "smooth", block: "center" });
const timer = window.setTimeout(() => setFocusedMessage(activeChatId, null), 2500);
return () => window.clearTimeout(timer);
}, [activeChatId, focusedMessageId, messages.length, setFocusedMessage]);
if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
}
const chatId = activeChatId;
async function ensureReactionsLoaded(messageId: number) {
if (reactionsByMessage[messageId]) {
return;
}
try {
const rows = await listMessageReactions(messageId);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleToggleReaction(messageId: number, emoji: string) {
try {
const rows = await toggleMessageReaction(messageId, emoji);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleForwardSubmit() {
if (!forwardMessageId) return;
const targetChatIds = [...forwardSelectedChatIds];
if (!targetChatIds.length) {
setForwardError("Select at least one chat");
return;
}
setIsForwarding(true);
setForwardError(null);
try {
await forwardMessageBulk(forwardMessageId, targetChatIds);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setForwardQuery("");
} catch {
setForwardError("Failed to forward message");
} finally {
setIsForwarding(false);
}
setCtx(null);
}
async function handlePin(messageId: number) {
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
const chat = await pinMessage(chatId, nextPinned);
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
setCtx(null);
}
async function handleDelete(forAll: boolean) {
if (!deleteMessageId) {
return;
}
try {
await deleteMessage(deleteMessageId, forAll);
removeMessage(chatId, deleteMessageId);
setDeleteMessageId(null);
setDeleteError(null);
} catch {
setDeleteError("Failed to delete message");
}
}
function toggleSelected(messageId: number) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
return next;
});
}
async function commitPendingDelete(state: PendingDeleteState) {
if (!state) {
return;
}
await Promise.allSettled(state.messages.map((message) => deleteMessage(message.id, false)));
setPendingDelete((current) => {
if (!current || current.timerId !== state.timerId) {
return current;
}
return null;
});
}
async function deleteSelectedForMe() {
if (!selectedMessages.length) {
return;
}
if (pendingDelete) {
window.clearTimeout(pendingDelete.timerId);
await commitPendingDelete(pendingDelete);
}
for (const message of selectedMessages) {
removeMessage(chatId, message.id);
}
const timeoutMs = 6000;
const timerId = window.setTimeout(() => {
void commitPendingDelete({
chatId,
messages: selectedMessages,
expiresAt: Date.now() + timeoutMs,
timerId
});
}, timeoutMs);
setPendingDelete({
chatId,
messages: selectedMessages,
expiresAt: Date.now() + timeoutMs,
timerId
});
setSelectedIds(new Set());
}
async function deleteSelectedForEveryone() {
if (!selectedMessages.length) {
return;
}
const results = await Promise.allSettled(
selectedMessages.map(async (message) => {
await deleteMessage(message.id, true);
return message.id;
})
);
for (const result of results) {
if (result.status === "fulfilled") {
removeMessage(chatId, result.value);
}
}
if (results.some((r) => r.status === "rejected")) {
setDeleteError("Some messages could not be deleted for everyone");
} else {
setDeleteError(null);
}
setSelectedIds(new Set());
}
function undoDeleteForMe() {
if (!pendingDelete || pendingDelete.chatId !== chatId) {
setPendingDelete(null);
return;
}
window.clearTimeout(pendingDelete.timerId);
restoreMessages(chatId, pendingDelete.messages);
setPendingDelete(null);
}
return (
<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"}
</div>
) : null}
{selectedIds.size > 0 ? (
<div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/80 px-3 py-2 text-xs">
<span className="font-semibold text-slate-200">{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
<button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}>
Delete for me
</button>
{canDeleteAllForSelection ? (
<button className="rounded bg-red-600 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForEveryone()}>
Delete for everyone
</button>
) : null}
<button className="rounded bg-slate-700 px-2 py-1 text-slate-200" onClick={() => setSelectedIds(new Set())}>
Cancel
</button>
</div>
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-2 py-4 md:px-5">
{hasMore ? (
<div className="mb-3 flex justify-center">
<button
className="rounded-full border border-slate-700/80 bg-slate-900/70 px-3 py-1 text-xs text-slate-300 hover:bg-slate-800 disabled:opacity-60"
disabled={isLoadingMore}
onClick={() => void loadMoreMessages(chatId)}
>
{isLoadingMore ? "Loading..." : "Load older messages"}
</button>
</div>
) : null}
{messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id;
const prev = messageIndex > 0 ? messages[messageIndex - 1] : null;
const groupedWithPrev = Boolean(
prev &&
prev.sender_id === message.sender_id &&
Math.abs(new Date(message.created_at).getTime() - new Date(prev.created_at).getTime()) < 4 * 60 * 1000
);
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? [];
return (
<div key={`${message.id}-${message.client_message_id ?? ""}`}>
{unreadBoundaryIndex === messageIndex ? (
<div className="mb-2 mt-1 flex items-center gap-2 px-1">
<span className="h-px flex-1 bg-slate-700/60" />
<span className="rounded-full border border-slate-700/70 bg-slate-900/80 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-sky-300">
New messages
</span>
<span className="h-px flex-1 bg-slate-700/60" />
</div>
) : null}
<div className={`${groupedWithPrev ? "mb-1" : "mb-2"} flex ${own ? "justify-end" : "justify-start"}`}>
<div
id={`message-${message.id}`}
className={`max-w-[90%] px-3 py-2.5 shadow-sm md:max-w-[70%] ${
own
? `${groupedWithPrev ? "rounded-2xl rounded-tr-md" : "rounded-2xl rounded-br-md"} bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950`
: `${groupedWithPrev ? "rounded-2xl rounded-tl-md" : "rounded-2xl rounded-bl-md"} border border-slate-700/60 bg-slate-900/80 text-slate-100`
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
toggleSelected(message.id);
}
}}
onContextMenu={(event) => {
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, attachmentUrl: getMessageAttachmentUrl(message) });
}}
>
{selectedIds.size > 0 ? (
<div
className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${
isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"
}`}
>
{isSelected ? "✓" : ""}
</div>
) : null}
{message.forwarded_from_message_id ? (
<div
className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
own
? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
: "border-sky-400 bg-slate-800/60 text-sky-300"
}`}
>
Forwarded message
</div>
) : null}
{replySource ? (
<div
className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
own
? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
: "border-sky-400 bg-slate-800/60 text-slate-300"
}`}
>
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
<p className="truncate">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderMessageContent(message.type, message.text, {
onAttachmentContextMenu: (event, url) => {
event.preventDefault();
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 });
},
onOpenMedia: (url, type) => {
const items = messages
.filter((m) => (m.type === "image" || m.type === "video" || m.type === "circle_video") && !!m.text)
.map((m) => ({ url: m.text as string, type: (m.type === "image" ? "image" : "video") as "image" | "video" }));
const idx = items.findIndex((i) => i.url === url && i.type === type);
setMediaViewer({ items, index: idx >= 0 ? idx : 0 });
},
})}
{messageReactions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{messageReactions.map((reaction) => (
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
reaction.reacted
? "border-sky-300 bg-sky-500/30"
: own
? "border-slate-900/30 bg-slate-900/10"
: "border-slate-600/60 bg-slate-800/60"
}`}
key={`${message.id}-${reaction.emoji}`}
>
{reaction.emoji} {reaction.count}
</span>
))}
</div>
) : null}
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? (
<span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span>
) : null}
</p>
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx
? createPortal(
<div
className="fixed z-[110] w-56 rounded-xl border border-slate-700/90 bg-slate-900/95 p-1.5 shadow-2xl"
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">
{QUICK_REACTIONS.map((emoji) => (
<button
className="rounded-md px-1.5 py-1 text-sm hover:bg-slate-700"
key={emoji}
onClick={() => void handleToggleReaction(ctx.messageId, emoji)}
type="button"
>
{emoji}
</button>
))}
</div>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
>
Reply
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setForwardMessageId(ctx.messageId);
setForwardQuery("");
setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null);
}}
>
Forward
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setSelectedIds(new Set([ctx.messageId]));
setCtx(null);
}}
>
Select
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteMessageId(ctx.messageId);
setDeleteError(null);
setCtx(null);
}}
>
Delete
</button>
<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>
{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);
showToast("File downloaded");
} catch {
showToast("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);
showToast("Link copied");
} catch {
showToast("Copy failed");
} finally {
setCtx(null);
}
}}
type="button"
>
Copy link
</button>
</>
) : null}
</div>,
document.body
)
: null}
{forwardMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setForwardMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Forward message</p>
<input
className="mb-2 w-full rounded-lg border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Search chats"
value={forwardQuery}
onChange={(event) => setForwardQuery(event.target.value)}
/>
<div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
{forwardTargets.map((chat) => (
<button
className={`block w-full rounded-lg px-3 py-2 text-left text-sm disabled:opacity-60 ${
forwardSelectedChatIds.has(chat.id) ? "bg-sky-500/30" : "bg-slate-800 hover:bg-slate-700"
}`}
disabled={isForwarding}
key={chat.id}
onClick={() => {
setForwardSelectedChatIds((prev) => {
const next = new Set(prev);
if (next.has(chat.id)) {
next.delete(chat.id);
} else {
next.add(chat.id);
}
return next;
});
}}
>
<p className="truncate font-semibold">{chatLabel(chat)}</p>
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
</button>
))}
{forwardTargets.length === 0 ? <p className="px-1 py-2 text-xs text-slate-400">No chats found</p> : null}
</div>
{forwardError ? <p className="mt-2 text-xs text-red-400">{forwardError}</p> : null}
<div className="mt-3 flex gap-2">
<button className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950" onClick={() => void handleForwardSubmit()}>
Forward ({forwardSelectedChatIds.size})
</button>
<button className="w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setForwardMessageId(null)}>
Cancel
</button>
</div>
</div>
</div>
) : null}
{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)}>
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
<button
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"
onClick={() => setMediaViewer(null)}
type="button"
>
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}
{deleteMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Delete message</p>
<p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p>
<div className="space-y-2">
<button className="w-full rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(false)}>
Delete for me
</button>
{canDeleteForEveryone(messagesMap.get(deleteMessageId), activeChat, me?.id) ? (
<button className="w-full rounded bg-red-600 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(true)}>
Delete for everyone
</button>
) : null}
<button className="w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteMessageId(null)}>
Cancel
</button>
</div>
{deleteError ? <p className="mt-2 text-xs text-red-400">{deleteError}</p> : null}
</div>
</div>
) : null}
{pendingDelete && pendingDelete.chatId === chatId ? (
<div className="pointer-events-none absolute bottom-3 left-0 right-0 z-50 flex justify-center px-3" data-tick={undoTick}>
<div className="pointer-events-auto flex w-full max-w-sm items-center justify-between rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs shadow-xl">
<span className="text-slate-200">
Messages deleted
{` (${Math.max(0, Math.ceil((pendingDelete.expiresAt - Date.now()) / 1000))}s)`}
</span>
<button className="rounded bg-sky-500 px-2 py-1 font-semibold text-slate-950" onClick={undoDeleteForMe} type="button">
Undo
</button>
</div>
</div>
) : null}
</div>
);
}
function renderMessageContent(
messageType: string,
text: string | null,
opts: {
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
onOpenMedia: (url: string, type: "image" | "video") => void;
}
) {
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") {
return (
<button
className="block overflow-hidden rounded-xl bg-slate-950/30"
onClick={() => opts.onOpenMedia(text, "image")}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, text);
}}
type="button"
>
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
</button>
);
}
if (messageType === "video" || messageType === "circle_video") {
return (
<button
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
onClick={() => opts.onOpenMedia(text, "video")}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, text);
}}
type="button"
>
<video className="max-h-80 rounded-xl" muted src={text} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</button>
);
}
if (messageType === "voice") {
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, text);
}}>
<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="font-semibold">Voice message</span>
</div>
<AudioInlinePlayer src={text} />
</div>
);
}
if (messageType === "audio") {
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, text);
}}>
<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>
<div className="min-w-0">
<p className="truncate font-semibold text-slate-200">{extractFileName(text)}</p>
<p className="text-[11px] text-slate-400">Audio file</p>
</div>
</div>
<AudioInlinePlayer src={text} />
</div>
);
}
if (messageType === "file") {
return (
<button
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, text);
}}
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
type="button"
>
<p className="text-xs uppercase tracking-wide text-slate-400">Document</p>
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(text)}</p>
</button>
);
}
return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
}
function renderStatus(status: string | undefined): string {
if (status === "sending") return "⌛";
if (status === "delivered") return "✓✓";
if (status === "read") return "✓✓";
return "✓";
}
function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } {
const pad = 8;
const cursorOffset = 4;
const wantedX = x + cursorOffset;
const wantedY = y + cursorOffset;
const safeX = Math.min(Math.max(pad, wantedX), window.innerWidth - menuWidth - pad);
const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad);
return { x: safeX, y: safeY };
}
function chatLabel(chat: { display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string {
if (chat.display_title?.trim()) return chat.display_title;
if (chat.title?.trim()) return chat.title;
if (chat.is_saved) return "Saved Messages";
if (chat.type === "private") return "Direct chat";
if (chat.type === "group") return "Group";
return "Channel";
}
function canDeleteForEveryone(
message: { sender_id: number } | undefined,
chat: { type: "private" | "group" | "channel"; is_saved?: boolean } | undefined,
meId: number | undefined
): boolean {
if (!message || !chat || !meId) return false;
if (chat.is_saved) return false;
if (chat.type === "private") return true;
return message.sender_id === meId;
}
function getMessageAttachmentUrl(message: Message): string | null {
const mediaTypes = new Set(["image", "video", "audio", "voice", "file", "circle_video"]);
if (!mediaTypes.has(message.type)) {
return null;
}
if (!message.text || !/^https?:\/\//i.test(message.text)) {
return null;
}
return message.text;
}
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);
}
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")}`;
}