import { useEffect, useMemo, useState } 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 PendingDeleteState = { chatId: number; messages: Message[]; expiresAt: number; timerId: number; } | null; 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 [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); 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); 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 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"); } } 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; } } 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)}> {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); return (
{unreadBoundaryIndex === messageIndex ? (
New messages
) : null}
{ if (selectedIds.size > 0) { toggleSelected(message.id); } }} onContextMenu={(e) => { e.preventDefault(); void ensureReactionsLoaded(message.id); const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108); 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} {renderContent(message.type, message.text)}
{["👍", "❤️", "🔥"].map((emoji) => { const items = reactionsByMessage[message.id] ?? []; const item = items.find((reaction) => reaction.emoji === emoji); return ( ); })}

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

); })}
{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}
{ctx ? createPortal(
e.stopPropagation()} >
, document.body ) : null} {forwardMessageId ? (
setForwardMessageId(null)}>
e.stopPropagation()}>

Forward message

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

No chats found

: null}
{forwardError ?

{forwardError}

: null}
) : null} {deleteMessageId ? (
setDeleteMessageId(null)}>
e.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 renderContent(messageType: string, text: string | null) { if (!text) return

[empty]

; if (messageType === "image") return attachment; if (messageType === "video" || messageType === "circle_video") return