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"; import { getUserById } from "../api/users"; import type { AuthUser } from "../chat/types"; 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 recordingVoiceByChat = useChatStore((s) => s.recordingVoiceByChat); const recordingVideoByChat = useChatStore((s) => s.recordingVideoByChat); 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(null); const [forwardMessageIds, setForwardMessageIds] = useState([]); const [forwardQuery, setForwardQuery] = useState(""); const [forwardError, setForwardError] = useState(null); const [isForwarding, setIsForwarding] = useState(false); const [forwardSelectedChatIds, setForwardSelectedChatIds] = useState>(new Set()); const [forwardIncludeAuthor, setForwardIncludeAuthor] = useState(true); 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 [attachmentsByMessage, setAttachmentsByMessage] = useState>({}); const [mediaViewer, setMediaViewer] = useState(null); const [threadRootId, setThreadRootId] = useState(null); const [threadMessages, setThreadMessages] = useState([]); const [threadLoading, setThreadLoading] = useState(false); const [threadError, setThreadError] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [senderProfiles, setSenderProfiles] = useState>>({}); const scrollContainerRef = useRef(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(); 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 typingCount = activeChatId ? (typingByChat[activeChatId] ?? []).length : 0; const recordingVoiceCount = activeChatId ? (recordingVoiceByChat[activeChatId] ?? []).length : 0; const recordingVideoCount = activeChatId ? (recordingVideoByChat[activeChatId] ?? []).length : 0; const activityLabel = recordingVideoCount > 0 ? "recording video..." : recordingVoiceCount > 0 ? "recording voice..." : typingCount > 0 ? "typing..." : ""; 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); setForwardMessageIds([]); 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); setForwardMessageIds([]); setForwardSelectedChatIds(new Set()); setForwardIncludeAuthor(true); if (activeChatId) { setEditingMessage(activeChatId, null); } setThreadRootId(null); setThreadMessages([]); setThreadError(null); setReactionsByMessage({}); setAttachmentsByMessage({}); setSenderProfiles({}); }, [activeChatId, setEditingMessage]); useEffect(() => { if (!activeChatId || activeChat?.type !== "group") { return; } const senderIds = [...new Set(messages.map((message) => message.sender_id).filter((id) => id !== me?.id))]; const missing = senderIds.filter((id) => !senderProfiles[id]); if (!missing.length) { return; } let cancelled = false; void (async () => { const rows = await Promise.allSettled(missing.map((id) => getUserById(id))); if (cancelled) { return; } const patch: Record> = {}; for (const row of rows) { if (row.status === "fulfilled") { patch[row.value.id] = { id: row.value.id, name: row.value.name, username: row.value.username, avatar_url: row.value.avatar_url ?? null, }; } } if (Object.keys(patch).length) { setSenderProfiles((prev) => ({ ...prev, ...patch })); } })(); return () => { cancelled = true; }; }, [activeChat?.type, activeChatId, me?.id, messages, senderProfiles]); useEffect(() => { if (!activeChatId) { setAttachmentsByMessage({}); return; } let cancelled = false; void (async () => { try { const rows = await getChatAttachments(activeChatId, 400); if (cancelled) { return; } const grouped: Record = {}; 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
Select a chat
; } const chatId = activeChatId; function scrollToBottom() { const container = scrollContainerRef.current; if (!container) { return; } container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); } function jumpToMessageInChat(messageId: number) { setFocusedMessage(chatId, messageId); setThreadRootId(null); } 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 (!forwardMessageIds.length) return; const targetChatIds = [...forwardSelectedChatIds]; if (!targetChatIds.length) { setForwardError("Select at least one chat"); return; } setIsForwarding(true); setForwardError(null); try { await Promise.all( forwardMessageIds.map((messageId) => forwardMessageBulk(messageId, targetChatIds, forwardIncludeAuthor)) ); setForwardMessageIds([]); setForwardSelectedChatIds(new Set()); setForwardIncludeAuthor(true); setForwardQuery(""); setSelectedIds(new Set()); } catch { setForwardError("Failed to forward message"); } finally { setIsForwarding(false); } setCtx(null); } function openForwardDialog(messageIds: number[]) { if (!messageIds.length) { return; } setForwardMessageIds([...new Set(messageIds)]); setForwardQuery(""); setForwardError(null); setForwardSelectedChatIds(new Set()); setForwardIncludeAuthor(true); } 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 (
{ setCtx(null); }}> {activeChat?.pinned_message_id ? (
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
) : null} {selectedIds.size > 0 ? (
{selectedIds.size} selected
{!channelOnlyDeleteForAll ? ( ) : null} {canDeleteAllForSelection ? ( ) : null}
) : null}
{ const target = event.currentTarget; const distance = target.scrollHeight - target.scrollTop - target.clientHeight; setShowScrollToBottom(distance > 180); }} > {hasMore ? (
) : 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 showSenderName = !own && activeChat?.type === "group" && !groupedWithPrev; const senderName = resolveSenderName(message.sender_id, senderProfiles, me, activeChat); const senderColor = senderNameColor(message.sender_id); const isSelected = selectedIds.has(message.id); const messageReactions = reactionsByMessage[message.id] ?? []; const showSenderAvatar = !own && activeChat?.type === "group" && !groupedWithPrev; const senderAvatarUrl = senderProfiles[message.sender_id]?.avatar_url ?? null; return (
{unreadBoundaryIndex === messageIndex ? (
New messages
) : null}
{!own && activeChat?.type === "group" ? ( showSenderAvatar ? ( senderAvatarUrl ? ( {senderName} ) : (
{senderInitials(senderName)}
) ) : (
) ) : 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, attachmentUrl: getMessageAttachmentUrl(message, attachmentsByMessage[message.id] ?? []), }); }} > {selectedIds.size > 0 ? (
{isSelected ? "✓" : ""}
) : null} {message.forwarded_from_message_id ? (
↪ Forwarded message
) : null} {replySource ? (

{replySource.sender_id === me?.id ? "You" : resolveSenderName(replySource.sender_id, senderProfiles, me, activeChat)}

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

) : null} {showSenderName ? (

{senderName}

) : 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 ? (
{messageReactions.map((reaction) => ( {reaction.emoji} {reaction.count} ))}
) : null}

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

); })}
{activityLabel}
{showScrollToBottom ? (
) : null} {ctx ? createPortal(
setCtx(null)}>
event.stopPropagation()} >
{QUICK_REACTIONS.map((emoji) => ( ))}
{canEditMessage(messagesMap.get(ctx.messageId), me?.id) ? ( ) : null} {canDeleteCurrentContextMessage ? ( ) : null} {ctx.attachmentUrl ? ( <>
) : null}
, document.body ) : null} {forwardMessageIds.length > 0 ? (
setForwardMessageIds([])}>
event.stopPropagation()}>

Forward {forwardMessageIds.length > 1 ? `${forwardMessageIds.length} messages` : "message"}

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

No chats found

: null}
{forwardError ?

{forwardError}

: null}
) : null} {mediaViewer ? ( setMediaViewer(null)} onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))} onToast={showToast} open /> ) : null} {threadRootId ? (
setThreadRootId(null)}>
event.stopPropagation()}>

Thread

{threadLoading ?

Loading...

: null} {threadError ?

{threadError}

: null} {!threadLoading && !threadError && threadMessages.length === 0 ?

No replies yet

: null}
{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 (
jumpToMessageInChat(threadMessage.id)} style={{ marginLeft: `${indent}px` }} >

{isRoot ? "Original message" : `Reply • level ${depth}`} • {formatTime(threadMessage.created_at)}

{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 }); } }, })}
); })}
) : null} {deleteMessageId ? (
setDeleteMessageId(null)}>
event.stopPropagation()}>

Delete message

{channelOnlyDeleteForAll ? "In channels, messages can only be deleted for everyone." : "Choose how to delete this message."}

{!channelOnlyDeleteForAll ? ( ) : null} {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( message: Message, opts: { attachments: ChatAttachment[]; onAttachmentContextMenu: (event: MouseEvent, 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

[empty]

; } if (mediaItems.length === 1) { const item = mediaItems[0]; const blockViewerOpen = isStickerOrGifMedia(item.url); return (
{captionText ? (

) : null}

); } const gridClass = getMediaGridClass(mediaItems.length); return (
event.stopPropagation()}>
{mediaItems.slice(0, 6).map((item, index) => { const tileClass = getMediaTileClass(mediaItems.length, index); const blockViewerOpen = isStickerOrGifMedia(item.url); return ( ); })}
{captionText ? (

) : null}

); } if (messageType === "voice") { const voiceItems = attachments.filter((item) => item.file_type.startsWith("audio/")); const items = voiceItems.length ? voiceItems : (text ? legacyAttachment : []); if (!items.length) { return

[empty]

; } return (
{items.map((item, index) => (
{ event.stopPropagation(); opts.onAttachmentContextMenu(event, item.file_url); }} >
))}
); } if (messageType === "audio") { const audioItems = attachments.filter((item) => item.file_type.startsWith("audio/")); const items = audioItems.length ? audioItems : (text ? legacyAttachment : []); if (!items.length) { return

[empty]

; } return (
{items.map((item, index) => (
{ event.stopPropagation(); opts.onAttachmentContextMenu(event, item.file_url); }} >
🎵

{extractFileName(item.file_url)}

Audio

))}
); } if (messageType === "file") { const fileItems = attachments.length ? attachments : (text ? legacyAttachment : []); if (fileItems.length) { return (
{fileItems.map((item, index) => ( ))} {captionText ? (

) : null}

); } } if (!text) { return

[empty]

; } const firstUrl = extractFirstUrl(text); 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 isStickerOrGifMedia(url: string): boolean { const value = url.toLowerCase(); if (value.includes("twemoji")) { return true; } if (value.includes("giphy.com") || value.includes("tenor.com") || value.includes("tenor.googleapis.com")) { return true; } return /\.gif($|\?)/.test(value); } function extractFirstUrl(text: string): string | null { const match = text.match(/\bhttps?:\/\/[^\s<>"')]+/i); if (!match) { return null; } const candidate = match[0].trim(); try { const parsed = new URL(candidate); if (parsed.protocol === "http:" || parsed.protocol === "https:") { return parsed.toString(); } } catch { return null; } return null; } function extractDomain(url: string): string { try { return new URL(url).hostname; } catch { return "Link"; } } function truncateLink(url: string, limit: number): string { if (url.length <= limit) { return url; } return `${url.slice(0, Math.max(0, limit - 1))}…`; } 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 ): Array<{ url: string; type: "image" | "video" }> { const items: Array<{ url: string; type: "image" | "video" }> = []; const seen = new Set(); 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; if (isStickerOrGifMedia(attachment.file_url)) 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; if (isStickerOrGifMedia(message.text)) 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 { 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 playbackRate = useAudioPlayerStore((s) => s.playbackRate); const cyclePlaybackRate = useAudioPlayerStore((s) => s.cyclePlaybackRate); 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 (
{title} {formatAudioTime(position)} / {formatAudioTime(duration)}
onSeek(Number(event.target.value))} step={0.1} type="range" value={Math.min(position, Math.max(duration, 0.01))} />
); } 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 playbackRate = useAudioPlayerStore((s) => s.playbackRate); const cyclePlaybackRate = useAudioPlayerStore((s) => s.cyclePlaybackRate); 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 (
{bars.map((value, index) => (
{formatAudioTime(duration > 0 ? duration : position)}
); } 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")}`; } function formatSenderName( senderId: number, profiles: Record> ): string { const profile = profiles[senderId]; if (profile?.name?.trim()) { return profile.name.trim(); } if (profile?.username?.trim()) { return `@${profile.username.trim()}`; } return `User #${senderId}`; } function resolveSenderName( senderId: number, profiles: Record>, me: AuthUser | null, activeChat: | { type: "private" | "group" | "channel"; is_saved?: boolean; counterpart_user_id?: number | null; counterpart_name?: string | null; counterpart_username?: string | null; display_title?: string | null; } | undefined ): string { const byProfile = formatSenderName(senderId, profiles); if (!byProfile.startsWith("User #")) { return byProfile; } if (senderId === me?.id) { if (me.name?.trim()) { return me.name.trim(); } if (me.username?.trim()) { return `@${me.username.trim()}`; } return "You"; } if (activeChat?.type === "private" && !activeChat.is_saved && activeChat.counterpart_user_id === senderId) { if (activeChat.counterpart_name?.trim()) { return activeChat.counterpart_name.trim(); } if (activeChat.display_title?.trim()) { return activeChat.display_title.trim(); } if (activeChat.counterpart_username?.trim()) { return `@${activeChat.counterpart_username.trim()}`; } } return byProfile; } function senderNameColor(senderId: number): string { const hue = (senderId * 67) % 360; return `hsl(${hue} 85% 65%)`; } function senderInitials(name: string): string { const words = name .trim() .split(/\s+/) .filter(Boolean) .slice(0, 2); if (!words.length) { return "?"; } return words.map((word) => word[0]?.toUpperCase() ?? "").join(""); }