1027 lines
40 KiB
TypeScript
1027 lines
40 KiB
TypeScript
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")}`;
|
||
}
|