Files
Messenger/web/src/components/MessageList.tsx
benya f670305073
All checks were successful
CI / test (push) Successful in 27s
p0: hide invalid delete action for channel members
2026-03-08 14:05:10 +03:00

1432 lines
55 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import {
deleteMessage,
forwardMessageBulk,
getChatAttachments,
getMessageThread,
listMessageReactions,
pinMessage,
toggleMessageReaction
} from "../api/chats";
import type { ChatAttachment, Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useAudioPlayerStore } from "../store/audioPlayerStore";
import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
import { MediaViewer } from "./MediaViewer";
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 setEditingMessage = useChatStore((s) => s.setEditingMessage);
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 [attachmentsByMessage, setAttachmentsByMessage] = useState<Record<number, ChatAttachment[]>>({});
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
const [threadRootId, setThreadRootId] = useState<number | null>(null);
const [threadMessages, setThreadMessages] = useState<Message[]>([]);
const [threadLoading, setThreadLoading] = useState(false);
const [threadError, setThreadError] = useState<string | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(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 threadRows = useMemo(() => {
if (!threadRootId || !threadMessages.length) return [];
const byId = new Map(threadMessages.map((message) => [message.id, message]));
const memoDepth = new Map<number, number>();
const calcDepth = (message: Message): number => {
if (memoDepth.has(message.id)) return memoDepth.get(message.id) ?? 0;
if (message.id === threadRootId) {
memoDepth.set(message.id, 0);
return 0;
}
const parentId = message.reply_to_message_id ?? null;
const parent = parentId ? byId.get(parentId) : undefined;
if (!parent) {
memoDepth.set(message.id, 1);
return 1;
}
const depth = Math.min(12, calcDepth(parent) + 1);
memoDepth.set(message.id, depth);
return depth;
};
return threadMessages
.map((message) => ({ message, depth: calcDepth(message) }))
.sort((a, b) => a.message.id - b.message.id);
}, [threadMessages, threadRootId]);
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 channelOnlyDeleteForAll = activeChat?.type === "channel" && !activeChat?.is_saved;
const canDeleteAllForSelection = useMemo(
() => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)),
[selectedMessages, activeChat, me?.id]
);
const canDeleteCurrentContextMessage = useMemo(() => {
if (!ctx) {
return false;
}
const message = messagesMap.get(ctx.messageId);
if (!message) {
return false;
}
if (channelOnlyDeleteForAll) {
return canDeleteForEveryone(message, activeChat, me?.id);
}
return true;
}, [ctx, messagesMap, channelOnlyDeleteForAll, 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);
setThreadRootId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
setSelectedIds(new Set());
setCtx(null);
setDeleteMessageId(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
if (activeChatId) {
setEditingMessage(activeChatId, null);
}
setThreadRootId(null);
setThreadMessages([]);
setThreadError(null);
setReactionsByMessage({});
setAttachmentsByMessage({});
}, [activeChatId, setEditingMessage]);
useEffect(() => {
if (!activeChatId) {
setAttachmentsByMessage({});
return;
}
let cancelled = false;
void (async () => {
try {
const rows = await getChatAttachments(activeChatId, 400);
if (cancelled) {
return;
}
const grouped: Record<number, ChatAttachment[]> = {};
for (const row of rows) {
if (!grouped[row.message_id]) {
grouped[row.message_id] = [];
}
grouped[row.message_id].push(row);
}
for (const key of Object.keys(grouped)) {
grouped[Number(key)] = grouped[Number(key)].sort((a, b) => a.id - b.id);
}
setAttachmentsByMessage(grouped);
} catch {
if (!cancelled) {
setAttachmentsByMessage({});
}
}
})();
return () => {
cancelled = true;
};
}, [activeChatId, messages]);
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]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
setShowScrollToBottom(distance > 180);
}, [messages.length, activeChatId]);
if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
}
const chatId = activeChatId;
function scrollToBottom() {
const container = scrollContainerRef.current;
if (!container) {
return;
}
container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
}
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);
}
async function openThread(messageId: number) {
setThreadRootId(messageId);
setThreadLoading(true);
setThreadError(null);
try {
const rows = await getMessageThread(messageId, 150);
setThreadMessages(rows);
} catch {
setThreadMessages([]);
setThreadError("Failed to load thread");
} finally {
setThreadLoading(false);
}
}
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">
{!channelOnlyDeleteForAll ? (
<button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}>
Delete for me
</button>
) : null}
{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"
ref={scrollContainerRef}
onScroll={(event) => {
const target = event.currentTarget;
const distance = target.scrollHeight - target.scrollTop - target.clientHeight;
setShowScrollToBottom(distance > 180);
}}
>
{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, attachmentsByMessage[message.id] ?? []),
});
}}
>
{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, {
attachments: attachmentsByMessage[message.id] ?? [],
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 = collectMediaItems(messages, attachmentsByMessage);
const idx = items.findIndex((i) => i.url === url && i.type === type);
if (!items.length) {
return;
}
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>
{showScrollToBottom ? (
<div className="pointer-events-none absolute bottom-20 right-4 z-40 md:bottom-24">
<button
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-600/80 bg-slate-900/90 text-lg text-slate-100 shadow-lg hover:bg-slate-800"
onClick={scrollToBottom}
type="button"
title="Scroll to latest"
>
</button>
</div>
) : null}
{ctx
? createPortal(
<div className="fixed inset-0 z-[109]" onClick={() => setCtx(null)}>
<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>
{canEditMessage(messagesMap.get(ctx.messageId), me?.id) ? (
<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, null);
setEditingMessage(chatId, msg);
}
setCtx(null);
}}
>
Edit
</button>
) : null}
<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={() => {
void openThread(ctx.messageId);
setCtx(null);
}}
>
View thread
</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>
{canDeleteCurrentContextMessage ? (
<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>
) : null}
<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>
</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 ? (
<MediaViewer
index={mediaViewer.index}
items={mediaViewer.items}
onClose={() => setMediaViewer(null)}
onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
onToast={showToast}
open
/>
) : null}
{threadRootId ? (
<div className="absolute inset-0 z-[120] flex items-end justify-center bg-slate-950/60 p-3" onClick={() => setThreadRootId(null)}>
<div className="tg-scrollbar max-h-[72vh] w-full overflow-auto rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">Thread</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setThreadRootId(null)} type="button">
Close
</button>
</div>
{threadLoading ? <p className="text-xs text-slate-400">Loading...</p> : null}
{threadError ? <p className="text-xs text-red-400">{threadError}</p> : null}
{!threadLoading && !threadError && threadMessages.length === 0 ? <p className="text-xs text-slate-400">No replies yet</p> : null}
<div className="space-y-2">
{threadRows.map(({ message: threadMessage, depth }) => {
const own = threadMessage.sender_id === me?.id;
const isRoot = threadMessage.id === threadRootId;
const indent = Math.min(6, depth) * 14;
return (
<div
className={`rounded-xl border px-3 py-2 ${isRoot ? "border-sky-400/60 bg-sky-500/10" : "border-slate-700/70 bg-slate-800/60"}`}
key={`thread-${threadMessage.id}`}
style={{ marginLeft: `${indent}px` }}
>
<p className={`mb-1 text-[11px] ${own ? "text-sky-300" : "text-slate-400"}`}>
{isRoot ? "Original message" : `Reply • level ${depth}`} {formatTime(threadMessage.created_at)}
</p>
<div className={own ? "text-slate-100" : "text-slate-200"}>
{renderMessageContent(threadMessage, {
attachments: attachmentsByMessage[threadMessage.id] ?? [],
onAttachmentContextMenu: () => {},
onOpenMedia: (url, type) => {
const items = collectMediaItems(messages, attachmentsByMessage);
const idx = items.findIndex((i) => i.url === url && i.type === type);
if (items.length) {
setMediaViewer({ items, index: idx >= 0 ? idx : 0 });
}
},
})}
</div>
</div>
);
})}
</div>
</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">
{channelOnlyDeleteForAll
? "In channels, messages can only be deleted for everyone."
: "Choose how to delete this message."}
</p>
<div className="space-y-2">
{!channelOnlyDeleteForAll ? (
<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>
) : null}
{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(
message: Message,
opts: {
attachments: ChatAttachment[];
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
onOpenMedia: (url: string, type: "image" | "video") => void;
}
) {
const messageType = message.type;
const text = message.text;
const legacyAttachment: ChatAttachment[] =
text && /^https?:\/\//i.test(text)
? [
{
id: -1,
message_id: message.id,
sender_id: message.sender_id,
message_type: messageType,
message_created_at: message.created_at,
file_url: text,
file_type: guessFileTypeByMessageType(messageType),
file_size: 0,
waveform_points: message.attachment_waveform ?? null,
},
]
: [];
const attachments = opts.attachments.length > 0 ? opts.attachments : legacyAttachment;
const captionText =
text && (!/^https?:\/\//i.test(text) || opts.attachments.length > 0)
? text.trim()
: "";
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
const mediaItems = attachments
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
.map((item) => ({
url: item.file_url,
type: (item.file_type.startsWith("image/") ? "image" : "video") as "image" | "video",
}));
if (!mediaItems.length && text) {
mediaItems.push({ url: text, type: messageType === "image" ? "image" : "video" });
}
if (!mediaItems.length) {
return <p className="opacity-80">[empty]</p>;
}
if (mediaItems.length === 1) {
const item = mediaItems[0];
return (
<div className="space-y-1.5">
<button
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
onClick={() => opts.onOpenMedia(item.url, item.type)}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={item.url} />
) : (
<>
<video className="max-h-80 rounded-xl" muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</>
)}
</button>
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
</div>
);
}
const gridClass = getMediaGridClass(mediaItems.length);
return (
<div className="space-y-1.5" onContextMenu={(event) => event.stopPropagation()}>
<div className={`grid gap-1.5 rounded-xl ${gridClass}`}>
{mediaItems.slice(0, 6).map((item, index) => {
const tileClass = getMediaTileClass(mediaItems.length, index);
return (
<button
className={`relative overflow-hidden rounded-lg bg-slate-950/30 ${tileClass}`}
key={`${item.url}-${index}`}
onClick={() => opts.onOpenMedia(item.url, item.type)}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<img alt="attachment" className="h-full w-full object-cover" draggable={false} src={item.url} />
) : (
<>
<video className="h-full w-full object-cover" muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-2xl text-white/85"></span>
</>
)}
{index === 5 && mediaItems.length > 6 ? (
<span className="absolute inset-0 flex items-center justify-center bg-slate-950/60 text-lg font-semibold text-white">
+{mediaItems.length - 6}
</span>
) : null}
</button>
);
})}
</div>
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
</div>
);
}
if (messageType === "voice") {
const voiceItems = attachments.filter((item) => item.file_type.startsWith("audio/"));
const items = voiceItems.length ? voiceItems : (text ? legacyAttachment : []);
if (!items.length) {
return <p className="opacity-80">[empty]</p>;
}
return (
<div className="space-y-1.5">
{items.map((item, index) => (
<div
className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2"
key={`${item.file_url}-${index}`}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.file_url);
}}
>
<VoiceInlinePlayer
src={item.file_url}
title="Voice message"
waveform={item.waveform_points ?? message.attachment_waveform ?? null}
/>
</div>
))}
</div>
);
}
if (messageType === "audio") {
const audioItems = attachments.filter((item) => item.file_type.startsWith("audio/"));
const items = audioItems.length ? audioItems : (text ? legacyAttachment : []);
if (!items.length) {
return <p className="opacity-80">[empty]</p>;
}
return (
<div className="space-y-1.5">
{items.map((item, index) => (
<div
className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2"
key={`${item.file_url}-${index}`}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.file_url);
}}
>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/25 text-emerald-200">🎵</span>
<div className="min-w-0">
<p className="truncate font-semibold text-slate-100">{extractFileName(item.file_url)}</p>
<p className="text-[11px] text-slate-400">Audio</p>
</div>
</div>
<AudioInlinePlayer src={item.file_url} title={extractFileName(item.file_url)} />
</div>
))}
</div>
);
}
if (messageType === "file") {
const fileItems = attachments.length ? attachments : (text ? legacyAttachment : []);
if (fileItems.length) {
return (
<div className="space-y-1.5">
{fileItems.map((item, index) => (
<button
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
key={`${item.file_url}-${index}`}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.file_url);
}}
onClick={() => window.open(item.file_url, "_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(item.file_url)}</p>
</button>
))}
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
</div>
);
}
}
if (!text) {
return <p className="opacity-80">[empty]</p>;
}
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; my_role?: "owner" | "admin" | "member" | null } | undefined,
meId: number | undefined
): boolean {
if (!message || !chat || !meId) return false;
if (chat.is_saved) return false;
if (chat.type === "private") return true;
if (chat.type === "channel") {
return chat.my_role === "owner" || chat.my_role === "admin";
}
if (chat.type === "group" && (chat.my_role === "owner" || chat.my_role === "admin")) {
return true;
}
return message.sender_id === meId;
}
function canEditMessage(message: Message | undefined, meId: number | undefined): boolean {
if (!message || !meId) return false;
if (message.sender_id !== meId) return false;
return message.type === "text";
}
function guessFileTypeByMessageType(messageType: Message["type"]): string {
if (messageType === "image") return "image/jpeg";
if (messageType === "video" || messageType === "circle_video") return "video/mp4";
if (messageType === "audio" || messageType === "voice") return "audio/mpeg";
return "application/octet-stream";
}
function collectMediaItems(
messages: Message[],
attachmentsByMessage: Record<number, ChatAttachment[]>
): Array<{ url: string; type: "image" | "video" }> {
const items: Array<{ url: string; type: "image" | "video" }> = [];
const seen = new Set<string>();
for (const message of messages) {
const attachments = attachmentsByMessage[message.id] ?? [];
for (const attachment of attachments) {
if (!attachment.file_url) continue;
if (!attachment.file_type.startsWith("image/") && !attachment.file_type.startsWith("video/")) continue;
const type = attachment.file_type.startsWith("image/") ? "image" : "video";
const key = `${type}:${attachment.file_url}`;
if (seen.has(key)) continue;
seen.add(key);
items.push({ url: attachment.file_url, type });
}
if (attachments.length === 0 && message.text && /^https?:\/\//i.test(message.text)) {
if (message.type !== "image" && message.type !== "video" && message.type !== "circle_video") continue;
const type = message.type === "image" ? "image" : "video";
const key = `${type}:${message.text}`;
if (seen.has(key)) continue;
seen.add(key);
items.push({ url: message.text, type });
}
}
return items;
}
function getMediaGridClass(count: number): string {
if (count <= 1) return "grid-cols-1";
if (count === 2) return "grid-cols-2";
return "grid-cols-2 auto-rows-[90px] md:auto-rows-[120px]";
}
function getMediaTileClass(count: number, index: number): string {
if (count <= 2) {
return "h-32 md:h-40";
}
if (count === 3 && index === 0) {
return "row-span-2 h-full";
}
if (count === 5 && index === 0) {
return "row-span-2 h-full";
}
return "h-full";
}
function getMessageAttachmentUrl(message: Message, attachments: ChatAttachment[]): string | null {
if (attachments.length > 0 && attachments[0].file_url) {
return attachments[0].file_url;
}
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, title }: { src: string; title: string }) {
const track = useAudioPlayerStore((s) => s.track);
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
const durationGlobal = useAudioPlayerStore((s) => s.duration);
const positionGlobal = useAudioPlayerStore((s) => s.position);
const playTrack = useAudioPlayerStore((s) => s.playTrack);
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
const isActiveTrack = track?.src === src;
const isPlaying = isActiveTrack && isPlayingGlobal;
const duration = isActiveTrack ? durationGlobal : 0;
const position = isActiveTrack ? positionGlobal : 0;
async function togglePlay() {
if (isActiveTrack) {
await togglePlayGlobal();
return;
}
await playTrack({ src, title });
}
function onSeek(nextValue: number) {
if (!isActiveTrack) {
return;
}
seekToGlobal(nextValue);
}
return (
<div className="rounded-lg border border-slate-600/70 bg-slate-900/60 px-2 py-2">
<div className="flex items-center gap-2">
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/80 text-xs text-slate-950 hover:bg-emerald-400"
onClick={() => void togglePlay()}
type="button"
>
{isPlaying ? "❚❚" : "▶"}
</button>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-slate-300">
<span className="truncate">{title}</span>
<span className="tabular-nums">{formatAudioTime(position)} / {formatAudioTime(duration)}</span>
</div>
<input
className="h-1.5 w-full cursor-pointer accent-emerald-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))}
/>
</div>
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-800 px-1 text-[10px] text-slate-300">
</span>
</div>
</div>
);
}
function VoiceInlinePlayer({
src,
title,
waveform,
}: {
src: string;
title: string;
waveform: number[] | null;
}) {
const track = useAudioPlayerStore((s) => s.track);
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
const durationGlobal = useAudioPlayerStore((s) => s.duration);
const positionGlobal = useAudioPlayerStore((s) => s.position);
const playTrack = useAudioPlayerStore((s) => s.playTrack);
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
const isActiveTrack = track?.src === src;
const isPlaying = isActiveTrack && isPlayingGlobal;
const duration = isActiveTrack ? durationGlobal : 0;
const position = isActiveTrack ? positionGlobal : 0;
const bars = waveform && waveform.length >= 8 ? waveform : buildFallbackWaveform(src);
async function togglePlay() {
if (isActiveTrack) {
await togglePlayGlobal();
return;
}
await playTrack({ src, title });
}
function handleWaveClick(index: number) {
if (!isActiveTrack || duration <= 0) {
return;
}
const ratio = index / Math.max(1, bars.length - 1);
seekToGlobal(duration * ratio);
}
const progressRatio = duration > 0 ? Math.min(1, Math.max(0, position / duration)) : 0;
const activeBars = Math.floor(progressRatio * bars.length);
return (
<div className="flex items-center gap-2 rounded-lg bg-slate-900/40 px-2 py-1.5">
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-sky-500/80 text-xs text-slate-950 hover:bg-sky-400"
onClick={() => void togglePlay()}
type="button"
>
{isPlaying ? "❚❚" : "▶"}
</button>
<div className="flex min-w-0 flex-1 items-end gap-[2px]">
{bars.map((value, index) => (
<button
className={`w-[3px] rounded-full p-0 ${index <= activeBars ? "bg-sky-300" : "bg-slate-500/60"}`}
key={`${src}-${index}`}
onClick={() => handleWaveClick(index)}
style={{ height: `${Math.max(6, value)}px` }}
type="button"
/>
))}
</div>
<span className="w-10 text-right text-[11px] tabular-nums text-slate-200">
{formatAudioTime(duration > 0 ? duration : position)}
</span>
</div>
);
}
function buildFallbackWaveform(seed: string, bars = 48): number[] {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
const result: number[] = [];
let value = hash || 1;
for (let i = 0; i < bars; i += 1) {
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
const normalized = Math.abs(value % 20) + 6;
result.push(normalized);
}
return result;
}
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")}`;
}