import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { addChatMember, banChatMember, createInviteLink, deleteChat, 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 [descriptionDraft, setDescriptionDraft] = 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 canViewMembersList = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin")); const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved && canViewMembersList); 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 canLeaveGroupLikeChat = useMemo(() => { if (!chat || !isGroupLike) { return false; } if (myRoleNormalized !== "owner") { return true; } const count = chat.members_count ?? members.length; return count <= 1; }, [chat, isGroupLike, myRoleNormalized, members.length]); 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().replace(/^@+/, "").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().replace(/^@+/, "").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.allSettled(missingIds.map((id) => getUserById(id))); for (const profile of profiles) { if (profile.status === "fulfilled") { byId[profile.value.id] = profile.value; } } } setMemberUsers(byId); return nextMembers; } async function refreshBans(targetChatId: number, allowFailure = true) { try { const nextBans = await listChatBans(targetChatId); setBans(nextBans); const ids = [...new Set(nextBans.flatMap((item) => [item.user_id, item.banned_by_user_id]))]; const profiles = await Promise.allSettled(ids.map((id) => getUserById(id))); const byId: Record = {}; for (const profile of profiles) { if (profile.status === "fulfilled") { byId[profile.value.id] = profile.value; } } 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 ?? ""); setDescriptionDraft(detail.description ?? ""); 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 resolvedRole = String(detail.my_role ?? "").toLowerCase(); const canLoadMembers = (detail.type === "group" || detail.type === "channel") && (resolvedRole === "owner" || resolvedRole === "admin"); if (canLoadMembers) { await refreshMembers(targetChatId); } else { setMembers([]); setMemberUsers({}); } 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(); }} >