import { useEffect, useMemo, 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 { formatTime } from "../utils/format"; import { formatMessageHtml } from "../utils/formatMessage"; type ContextMenuState = { x: number; y: number; messageId: number; } | null; type AttachmentMenuState = { x: number; y: number; url: string; } | null; type PendingDeleteState = { chatId: number; messages: Message[]; expiresAt: number; timerId: 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 [ctx, setCtx] = useState(null); const [attachmentCtx, setAttachmentCtx] = useState(null); const [forwardMessageId, setForwardMessageId] = useState(null); const [forwardQuery, setForwardQuery] = useState(""); const [forwardError, setForwardError] = useState(null); const [isForwarding, setIsForwarding] = useState(false); const [forwardSelectedChatIds, setForwardSelectedChatIds] = useState>(new Set()); const [deleteMessageId, setDeleteMessageId] = useState(null); const [deleteError, setDeleteError] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [pendingDelete, setPendingDelete] = useState(null); const [undoTick, setUndoTick] = useState(0); const [reactionsByMessage, setReactionsByMessage] = useState>({}); 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); setAttachmentCtx(null); setForwardMessageId(null); setForwardSelectedChatIds(new Set()); setDeleteMessageId(null); setSelectedIds(new Set()); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); useEffect(() => { setSelectedIds(new Set()); setCtx(null); setAttachmentCtx(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
Select a chat
; } 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 (
{ setCtx(null); setAttachmentCtx(null); }}> {activeChat?.pinned_message_id ? (
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
) : null} {selectedIds.size > 0 ? (
{selectedIds.size} selected
{canDeleteAllForSelection ? ( ) : null}
) : null}
{hasMore ? (
) : null} {messages.map((message, messageIndex) => { const own = message.sender_id === me?.id; 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 (
{unreadBoundaryIndex === messageIndex ? (
New messages
) : null}
{ 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 }); }} > {selectedIds.size > 0 ? (
{isSelected ? "✓" : ""}
) : null} {message.forwarded_from_message_id ? (
↪ Forwarded message
) : null} {replySource ? (

{replySource.sender_id === me?.id ? "You" : "Reply"}

{replySource.text || "[media]"}

) : null} {renderMessageContent(message.type, message.text, { onAttachmentContextMenu: (event, url) => { event.preventDefault(); const pos = getSafeContextPosition(event.clientX, event.clientY, 180, 110); setAttachmentCtx({ x: pos.x, y: pos.y, url }); } })} {messageReactions.length > 0 ? (
{messageReactions.map((reaction) => ( {reaction.emoji} {reaction.count} ))}
) : null}

{formatTime(message.created_at)} {own ? ( {renderStatus(message.delivery_status)} ) : null}

); })}
{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}
{ctx ? createPortal(
event.stopPropagation()} >
{QUICK_REACTIONS.map((emoji) => ( ))}
, document.body ) : null} {attachmentCtx ? createPortal(
event.stopPropagation()} > Open Download
, document.body ) : null} {forwardMessageId ? (
setForwardMessageId(null)}>
event.stopPropagation()}>

Forward message

setForwardQuery(event.target.value)} />
{forwardTargets.map((chat) => ( ))} {forwardTargets.length === 0 ?

No chats found

: null}
{forwardError ?

{forwardError}

: null}
) : null} {deleteMessageId ? (
setDeleteMessageId(null)}>
event.stopPropagation()}>

Delete message

Choose how to delete this message.

{canDeleteForEveryone(messagesMap.get(deleteMessageId), activeChat, me?.id) ? ( ) : null}
{deleteError ?

{deleteError}

: null}
) : null} {pendingDelete && pendingDelete.chatId === chatId ? (
Messages deleted {` (${Math.max(0, Math.ceil((pendingDelete.expiresAt - Date.now()) / 1000))}s)`}
) : null}
); } function renderMessageContent( messageType: string, text: string | null, opts: { onAttachmentContextMenu: (event: MouseEvent, url: string) => void } ) { if (!text) return

[empty]

; if (messageType === "image") { return (
opts.onAttachmentContextMenu(event, text)}> attachment
); } if (messageType === "video" || messageType === "circle_video") { return (
opts.onAttachmentContextMenu(event, text)}>
); } if (messageType === "voice") { return (
opts.onAttachmentContextMenu(event, text)}>
🎤 Voice message
); } if (messageType === "audio") { return (
opts.onAttachmentContextMenu(event, text)}>
🎵

{extractFileName(text)}

Audio file

); } if (messageType === "file") { return ( ); } return

; } 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 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"; } }