import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { addChatMember, createInviteLink, getChatAttachments, getMessages, getChatNotificationSettings, getChatDetail, leaveChat, listChatMembers, removeChatMember, updateChatNotificationSettings, updateChatMemberRole, updateChatTitle } from "../api/chats"; import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { useUiStore } from "../store/uiStore"; interface Props { chatId: number | null; open: boolean; onClose: () => void; } export function ChatInfoPanel({ chatId, open, onClose }: Props) { const me = useAuthStore((s) => s.me); const loadChats = useChatStore((s) => s.loadChats); const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const showToast = useUiStore((s) => s.showToast); const [chat, setChat] = useState(null); const [members, setMembers] = useState([]); const [memberUsers, setMemberUsers] = useState>({}); const [counterpartProfile, setCounterpartProfile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [titleDraft, setTitleDraft] = useState(""); const [savingTitle, setSavingTitle] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [muted, setMuted] = useState(false); const [savingMute, setSavingMute] = useState(false); const [counterpartBlocked, setCounterpartBlocked] = useState(false); const [savingBlock, setSavingBlock] = useState(false); const [inviteLink, setInviteLink] = useState(null); const [attachments, setAttachments] = useState([]); const [attachmentsLoading, setAttachmentsLoading] = useState(false); const [linkItems, setLinkItems] = useState>([]); const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string; messageId: number } | null>(null); const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null); const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all"); const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null); const myRole = useMemo(() => { if (chat?.my_role) { return chat.my_role; } return members.find((m) => m.user_id === me?.id)?.role; }, [chat?.my_role, members, me?.id]); const myRoleNormalized = useMemo(() => { if (!myRole) { return null; } const role = String(myRole).toLowerCase(); if (role === "owner" || role === "admin" || role === "member") { return role; } return null; }, [myRole]); const isGroupLike = chat?.type === "group" || chat?.type === "channel"; const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved); const canManageMembers = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin")); const canEditTitle = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin")); const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]); const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]); const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]); const audioAttachments = useMemo( () => attachments .filter((item) => item.message_type === "audio" || (item.file_type.startsWith("audio/") && item.message_type !== "voice")) .sort((a, b) => b.id - a.id), [attachments] ); const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]); async function refreshMembers(targetChatId: number) { const nextMembers = await listChatMembers(targetChatId); setMembers(nextMembers); const ids = [...new Set(nextMembers.map((m) => m.user_id))]; const profiles = await Promise.all(ids.map((id) => getUserById(id))); const byId: Record = {}; for (const profile of profiles) { byId[profile.id] = profile; } setMemberUsers(byId); } function jumpToMessage(messageId: number) { if (!chatId) { return; } setActiveChatId(chatId); setFocusedMessage(chatId, messageId); onClose(); } useEffect(() => { if (!open || !chatId) { return; } let cancelled = false; setLoading(true); setError(null); setAttachmentsLoading(true); void (async () => { try { const detail = await getChatDetail(chatId); if (cancelled) return; setChat(detail); setTitleDraft(detail.title ?? ""); const notificationSettings = await getChatNotificationSettings(chatId); if (!cancelled) { setMuted(notificationSettings.muted); } if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) { try { const counterpart = await getUserById(detail.counterpart_user_id); if (!cancelled) { setCounterpartProfile(counterpart); } const blocked = await listBlockedUsers(); if (!cancelled) { setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id)); } } catch { if (!cancelled) { setCounterpartProfile(null); setCounterpartBlocked(false); } } } else if (!cancelled) { setCounterpartProfile(null); setCounterpartBlocked(false); } await refreshMembers(chatId); const chatAttachments = await getChatAttachments(chatId, 120); const messages = await getRecentMessagesForLinks(chatId); if (!cancelled) { setAttachments(chatAttachments); setLinkItems(extractLinkItems(messages)); } } catch { if (!cancelled) setError("Failed to load chat info"); } finally { if (!cancelled) { setLoading(false); setAttachmentsLoading(false); } } })(); return () => { cancelled = true; }; }, [open, chatId]); useEffect(() => { if (!open) { return; } setInviteLink(null); setMembers([]); setMemberUsers({}); setSearchQuery(""); setSearchResults([]); }, [chatId, open]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { onClose(); } }; if (open) { window.addEventListener("keydown", onKeyDown); } return () => window.removeEventListener("keydown", onKeyDown); }, [open, onClose]); if (!open || !chatId) { return null; } return createPortal(
{ setAttachmentCtx(null); setMemberCtx(null); onClose(); }} > {attachmentCtx ? (
Open Download
) : null} {memberCtx ? (
event.stopPropagation()} > {myRoleNormalized === "owner" && memberCtx.member.role === "member" ? ( ) : null} {myRoleNormalized === "owner" && memberCtx.member.role === "admin" ? ( ) : null} {myRoleNormalized === "owner" && memberCtx.member.role !== "owner" ? ( ) : null} {(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? ( ) : null}
) : null} {mediaViewer ? (
setMediaViewer(null)}>
event.stopPropagation()}> {mediaViewer.items.length > 1 ? ( <> ) : null} {mediaViewer.items[mediaViewer.index].type === "image" ? ( media ) : (
) : null}
, document.body ); } function formatLastSeen(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return "recently"; } return date.toLocaleString(undefined, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); } 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 privateChatStatusLabel(chat: { counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null }): string { if (chat.counterpart_is_online) { return "online"; } if (chat.counterpart_last_seen_at) { return `last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`; } return "offline"; } function initialsFromName(value: string): string { const prepared = value.trim(); if (!prepared) { return "?"; } const parts = prepared.split(/\s+/).filter(Boolean); if (parts.length >= 2) { return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase(); } return (parts[0]?.slice(0, 2) ?? "?").toUpperCase(); } function formatBytes(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`; } 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"; } } function attachmentKind(fileType: string): string { if (fileType.startsWith("image/")) return "Photo"; if (fileType.startsWith("video/")) return "Video"; if (fileType.startsWith("audio/")) return "Audio"; if (fileType === "application/pdf") return "PDF"; return "File"; } async function getRecentMessagesForLinks(chatId: number): Promise { const pages = 4; const all: Message[] = []; let beforeId: number | undefined; for (let i = 0; i < pages; i += 1) { const batch = await getMessages(chatId, beforeId); if (!batch.length) { break; } all.push(...batch); beforeId = batch[batch.length - 1]?.id; if (!beforeId || batch.length < 50) { break; } } return all; } function extractLinkItems(messages: Message[]): Array<{ url: string; messageId: number; createdAt: string }> { const out: Array<{ url: string; messageId: number; createdAt: string }> = []; const seen = new Set(); const regex = /\bhttps?:\/\/[^\s<>"')]+/gi; for (const message of messages) { if (message.type !== "text" || !message.text) { continue; } const links = message.text.match(regex) ?? []; for (const raw of links) { const url = raw.trim(); const key = `${message.id}:${url}`; if (seen.has(key)) { continue; } seen.add(key); out.push({ url, messageId: message.id, createdAt: message.created_at }); } } out.sort((a, b) => b.messageId - a.messageId); return out; } function shortLink(url: string): string { try { const parsed = new URL(url); return `${parsed.hostname}${parsed.pathname === "/" ? "" : parsed.pathname}`; } catch { return url; } }