import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { addChatMember, banChatMember, createInviteLink, getChatAttachments, getMessages, getChatNotificationSettings, getChatDetail, leaveChat, listChatBans, listChatMembers, requestUploadUrl, removeChatMember, unbanChatMember, updateChatProfile, updateChatNotificationSettings, updateChatMemberRole, uploadToPresignedUrl } from "../api/chats"; import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; import type { AuthUser, ChatAttachment, ChatBan, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { useUiStore } from "../store/uiStore"; import { AvatarCropModal } from "./AvatarCropModal"; import { MediaViewer } from "./MediaViewer"; 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 updateChatMuted = useChatStore((s) => s.updateChatMuted); 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 [chatAvatarUploading, setChatAvatarUploading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [memberFilter, setMemberFilter] = useState(""); const [bannedFilter, setBannedFilter] = 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 [banCtx, setBanCtx] = useState<{ x: number; y: number; ban: ChatBan } | null>(null); const [bans, setBans] = useState([]); const [bannedUsers, setBannedUsers] = useState>({}); 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 [avatarCropFile, setAvatarCropFile] = useState(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 canEditChatProfile = 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]); const filteredMembers = useMemo(() => { const query = memberFilter.trim().toLowerCase(); if (!query) { return members; } return members.filter((member) => { const user = memberUsers[member.user_id]; return ( (user?.name ?? "").toLowerCase().includes(query) || (user?.username ?? "").toLowerCase().includes(query) || String(member.user_id).includes(query) ); }); }, [memberFilter, members, memberUsers]); const filteredBans = useMemo(() => { const query = bannedFilter.trim().toLowerCase(); if (!query) { return bans; } return bans.filter((ban) => { const user = bannedUsers[ban.user_id]; return ( (user?.name ?? "").toLowerCase().includes(query) || (user?.username ?? "").toLowerCase().includes(query) || String(ban.user_id).includes(query) ); }); }, [bannedFilter, bans, bannedUsers]); async function refreshMembers(targetChatId: number): Promise { const nextMembers = await listChatMembers(targetChatId); setMembers(nextMembers); const byId: Record = {}; const missingIds: number[] = []; for (const member of nextMembers) { if (member.name || member.username || member.avatar_url) { byId[member.user_id] = { id: member.user_id, email: "", name: member.name || member.username || `user #${member.user_id}`, username: member.username || `user${member.user_id}`, bio: null, avatar_url: member.avatar_url || null, email_verified: false, allow_private_messages: true, created_at: "", updated_at: "", }; } else { missingIds.push(member.user_id); } } if (missingIds.length) { const profiles = await Promise.all(missingIds.map((id) => getUserById(id))); for (const profile of profiles) { byId[profile.id] = profile; } } setMemberUsers(byId); return nextMembers; } async function refreshBans(targetChatId: number, allowFailure = true) { try { const nextBans = await listChatBans(targetChatId); setBans(nextBans); const ids = [...new Set(nextBans.map((item) => item.user_id))]; const profiles = await Promise.all(ids.map((id) => getUserById(id))); const byId: Record = {}; for (const profile of profiles) { byId[profile.id] = profile; } setBannedUsers(byId); } catch { setBans([]); setBannedUsers({}); if (!allowFailure) { throw new Error("failed_to_load_bans"); } } } async function refreshPanelData(targetChatId: number, withLoading = false) { if (withLoading) { setLoading(true); setError(null); setAttachmentsLoading(true); } try { const detail = await getChatDetail(targetChatId); setChat(detail); setTitleDraft(detail.title ?? ""); const notificationSettings = await getChatNotificationSettings(targetChatId); setMuted(notificationSettings.muted); if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) { try { const counterpart = await getUserById(detail.counterpart_user_id); setCounterpartProfile(counterpart); const blocked = await listBlockedUsers(); setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id)); } catch { setCounterpartProfile(null); setCounterpartBlocked(false); } } else { setCounterpartProfile(null); setCounterpartBlocked(false); } const nextMembers = await refreshMembers(targetChatId); const resolvedRole = String(detail.my_role ?? nextMembers.find((m) => m.user_id === me?.id)?.role ?? "").toLowerCase(); const canLoadBans = (detail.type === "group" || detail.type === "channel") && (resolvedRole === "owner" || resolvedRole === "admin"); if (canLoadBans) { await refreshBans(targetChatId); } else { setBans([]); setBannedUsers({}); } const chatAttachments = await getChatAttachments(targetChatId, 120); const messages = await getRecentMessagesForLinks(targetChatId); setAttachments(chatAttachments); setLinkItems(extractLinkItems(messages)); } catch { if (withLoading) { setError("Failed to load chat info"); } } finally { if (withLoading) { setLoading(false); setAttachmentsLoading(false); } } } function jumpToMessage(messageId: number) { if (!chatId) { return; } setActiveChatId(chatId); setFocusedMessage(chatId, messageId); onClose(); } async function uploadChatAvatar(file: File) { if (!chatId || !canEditChatProfile) { return; } setChatAvatarUploading(true); try { const upload = await requestUploadUrl(file); await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file); const updated = await updateChatProfile(chatId, { avatar_url: upload.file_url }); setChat((prev) => (prev ? { ...prev, ...updated } : prev)); await loadChats(); showToast("Chat avatar updated"); } catch { setError("Failed to upload chat avatar"); } finally { setChatAvatarUploading(false); } } useEffect(() => { if (!open || !chatId) { return; } let cancelled = false; void (async () => { await refreshPanelData(chatId, true); if (cancelled) { return; } })(); return () => { cancelled = true; }; }, [open, chatId]); useEffect(() => { if (!open) { return; } setInviteLink(null); setMembers([]); setMemberUsers({}); setBans([]); setBannedUsers({}); setSearchQuery(""); setSearchResults([]); setMemberFilter(""); setBannedFilter(""); }, [chatId, open]); useEffect(() => { if (!open || !chatId) { return; } const onRealtimeChatUpdated = (event: Event) => { const realtimeEvent = event as CustomEvent<{ chatId?: number }>; const updatedChatId = Number(realtimeEvent.detail?.chatId); if (!Number.isFinite(updatedChatId) || updatedChatId !== chatId) { return; } void refreshPanelData(chatId, false); }; window.addEventListener("bm:chat-updated", onRealtimeChatUpdated); return () => window.removeEventListener("bm:chat-updated", onRealtimeChatUpdated); }, [open, chatId]); useEffect(() => { if (!open || !chatId) { return; } const timer = window.setInterval(() => { void refreshPanelData(chatId, false); }, 15000); return () => window.clearInterval(timer); }, [open, chatId]); 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); setBanCtx(null); onClose(); }} > {attachmentCtx ? (
event.stopPropagation()} > 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} {(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? ( ) : null}
) : null} {banCtx ? (
event.stopPropagation()} >
) : null} {mediaViewer ? ( setMediaViewer(null)} onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))} onJumpToMessage={(messageId) => jumpToMessage(messageId)} onToast={showToast} open /> ) : null} { setAvatarCropFile(null); void uploadChatAvatar(processedFile); }} onCancel={() => setAvatarCropFile(null)} open={Boolean(avatarCropFile)} />
, document.body ); } function formatLastSeen(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return "recently"; } const now = new Date(); const diffMs = now.getTime() - date.getTime(); if (diffMs < 0) { return "recently"; } const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (diffMs < 2 * minute) { return "just now"; } if (diffMs < day) { return `today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; } if (diffMs < 2 * day) { return `yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; } if (diffMs < 7 * day) { 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; } }