Files
Messenger/web/src/components/ChatInfoPanel.tsx
benya 775236b483
Some checks failed
CI / test (push) Failing after 2m12s
feat(web): add banned users section in chat info moderation
2026-03-08 21:26:10 +03:00

1091 lines
46 KiB
TypeScript

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<ChatDetail | null>(null);
const [members, setMembers] = useState<ChatMember[]>([]);
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
const [counterpartProfile, setCounterpartProfile] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [titleDraft, setTitleDraft] = useState("");
const [savingTitle, setSavingTitle] = useState(false);
const [chatAvatarUploading, setChatAvatarUploading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<UserSearchItem[]>([]);
const [muted, setMuted] = useState(false);
const [savingMute, setSavingMute] = useState(false);
const [counterpartBlocked, setCounterpartBlocked] = useState(false);
const [savingBlock, setSavingBlock] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
const [linkItems, setLinkItems] = useState<Array<{ url: string; messageId: number; createdAt: string }>>([]);
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 [bans, setBans] = useState<ChatBan[]>([]);
const [bannedUsers, setBannedUsers] = useState<Record<number, AuthUser>>({});
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<File | 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 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]);
async function refreshMembers(targetChatId: number): Promise<ChatMember[]> {
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<number, AuthUser> = {};
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<number, AuthUser> = {};
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([]);
}, [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(() => {
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(
<div
className="fixed inset-0 z-[120] bg-slate-950/55"
onClick={() => {
setAttachmentCtx(null);
setMemberCtx(null);
onClose();
}}
>
<aside
className="absolute right-0 top-0 flex h-full w-full max-w-sm flex-col border-l border-slate-700/70 bg-slate-900/95 shadow-2xl"
onClick={(e) => {
setAttachmentCtx(null);
setMemberCtx(null);
e.stopPropagation();
}}
>
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
<p className="text-sm font-semibold">Chat info</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>Close</button>
</div>
<div className="tg-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-3">
{loading ? <p className="text-sm text-slate-300">Loading...</p> : null}
{error ? <p className="text-sm text-red-400">{error}</p> : null}
{chat ? (
<>
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-4">
<div className="flex items-center gap-3">
{(chat.type === "private" ? (counterpartProfile?.avatar_url || chat.counterpart_avatar_url) : chat.avatar_url) ? (
<img
alt="avatar"
className="h-16 w-16 rounded-full object-cover"
src={chat.type === "private" ? (counterpartProfile?.avatar_url || chat.counterpart_avatar_url || "") : (chat.avatar_url || "")}
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-sky-500/30 text-xl font-semibold uppercase text-sky-100">
{initialsFromName(chat.display_title || chat.title || counterpartProfile?.name || chat.type)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-base font-semibold">{chatLabel(chat)}</p>
<p className="truncate text-xs text-slate-400">
{chat.type === "private"
? privateChatStatusLabel(chat)
: chat.type === "group"
? `${chat.members_count ?? members.length} members, ${chat.online_count ?? 0} online`
: `${chat.subscribers_count ?? chat.members_count ?? members.length} subscribers`}
</p>
</div>
</div>
{chat.type === "private" && counterpartProfile?.username ? <p className="mt-3 text-xs text-sky-300">@{counterpartProfile.username}</p> : null}
{chat.type !== "private" && chat.handle ? <p className="mt-3 text-xs text-sky-300">@{chat.handle}</p> : null}
{chat.type === "private" && counterpartProfile?.bio ? <p className="mt-2 text-sm text-slate-300">{counterpartProfile.bio}</p> : null}
{chat.type !== "private" && chat.description ? <p className="mt-2 text-sm text-slate-300">{chat.description}</p> : null}
</div>
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-slate-400">Notifications</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-xs disabled:opacity-60"
disabled={savingMute}
onClick={async () => {
setSavingMute(true);
try {
const updated = await updateChatNotificationSettings(chatId, !muted);
setMuted(updated.muted);
updateChatMuted(chatId, updated.muted);
} catch {
setError("Failed to update notifications");
} finally {
setSavingMute(false);
}
}}
>
{muted ? "Unmute" : "Mute"}
</button>
</div>
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
{isGroupLike && canEditTitle ? (
<>
<p className="mt-2 text-xs text-slate-400">Avatar</p>
<div className="mt-1 flex items-center gap-2">
<label className="cursor-pointer rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">
{chatAvatarUploading ? "Uploading..." : "Upload avatar"}
<input
accept="image/*"
className="hidden"
disabled={chatAvatarUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (!file) {
return;
}
setAvatarCropFile(file);
}}
type="file"
/>
</label>
{chat.avatar_url ? (
<button
className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600"
onClick={async () => {
try {
const updated = await updateChatProfile(chatId, { avatar_url: null });
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
showToast("Chat avatar removed");
} catch {
setError("Failed to remove chat avatar");
}
}}
type="button"
>
Remove avatar
</button>
) : null}
</div>
<p className="mt-2 text-xs text-slate-400">Title</p>
<input
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
/>
<button
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={savingTitle || !titleDraft.trim() || chatAvatarUploading}
onClick={async () => {
setSavingTitle(true);
try {
const updated = await updateChatProfile(chatId, { title: titleDraft.trim() });
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
} catch {
setError("Failed to update title");
} finally {
setSavingTitle(false);
}
}}
>
Save title
</button>
</>
) : null}
{isGroupLike && canManageMembers ? (
<div className="mt-2">
<button
className="w-full rounded bg-slate-700 px-3 py-2 text-xs"
onClick={async () => {
try {
const link = await createInviteLink(chatId);
setInviteLink(link.invite_url);
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link.invite_url);
showToast("Invite link copied");
}
} catch {
setError("Failed to create invite link");
}
}}
>
Create invite link
</button>
{inviteLink ? <p className="mt-1 break-all text-[11px] text-sky-300">{inviteLink}</p> : null}
</div>
) : null}
</div>
{showMembersSection ? (
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Members ({members.length})</p>
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
{members.map((member) => {
const user = memberUsers[member.user_id];
const isSelf = member.user_id === me?.id;
const canOpenMemberMenu =
canManageMembers &&
!isSelf &&
(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && member.role === "member"));
return (
<button
className="block w-full rounded border border-slate-700/60 bg-slate-900/60 p-2 text-left hover:bg-slate-800/70"
key={member.id}
onContextMenu={(event) => {
if (!canOpenMemberMenu) {
return;
}
event.preventDefault();
setMemberCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 210),
y: Math.min(event.clientY + 4, window.innerHeight - 170),
member,
});
}}
type="button"
>
<p className="flex items-center gap-1 truncate text-sm font-semibold">
<span className="truncate">{user?.name || `user #${member.user_id}`}</span>
{member.role === "owner" ? <span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-300">👑 owner</span> : null}
{member.role === "admin" ? <span className="rounded bg-sky-500/20 px-1.5 py-0.5 text-[10px] text-sky-300">👑 admin</span> : null}
</p>
<p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
{canOpenMemberMenu ? <p className="mt-1 text-[11px] text-slate-500">Right click for actions</p> : null}
</button>
);
})}
</div>
</div>
) : (
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
{chat.is_saved ? (
<p className="text-sm text-slate-300">Saved Messages is your personal cloud chat.</p>
) : chat.type === "private" ? (
<p className="text-sm text-slate-300">
{chat.counterpart_is_online
? "User is online"
: chat.counterpart_last_seen_at
? `Last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`
: "User is offline"}
</p>
) : (
<p className="text-sm text-slate-300">No extra information.</p>
)}
</div>
)}
{showMembersSection && canManageMembers ? (
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Add member</p>
<input
className="mb-2 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
placeholder="@username"
value={searchQuery}
onChange={async (e) => {
const value = e.target.value;
setSearchQuery(value);
if (value.trim().replace("@", "").length < 2) {
setSearchResults([]);
return;
}
try {
const users = await searchUsers(value);
setSearchResults(users.filter((u) => !members.some((m) => m.user_id === u.id)));
} catch {
setError("Failed to search users");
}
}}
/>
<div className="tg-scrollbar max-h-40 space-y-1 overflow-auto">
{searchResults.map((user) => (
<button
className="block w-full rounded bg-slate-900/70 px-3 py-2 text-left text-sm hover:bg-slate-700"
key={user.id}
onClick={async () => {
try {
await addChatMember(chatId, user.id);
setSearchQuery("");
setSearchResults([]);
await refreshMembers(chatId);
} catch {
setError("Failed to add member");
}
}}
>
<p className="truncate font-semibold">{user.name}</p>
<p className="truncate text-xs text-slate-400">@{user.username}</p>
</button>
))}
</div>
</div>
) : null}
{showMembersSection && canManageMembers ? (
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Banned users ({bans.length})</p>
{bans.length === 0 ? <p className="text-xs text-slate-400">No banned users</p> : null}
<div className="tg-scrollbar max-h-44 space-y-1 overflow-auto">
{bans.map((ban) => {
const user = bannedUsers[ban.user_id];
return (
<div className="flex items-center justify-between rounded border border-slate-700/60 bg-slate-900/60 px-2 py-1.5" key={`ban-${ban.user_id}`}>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-200">{user?.name || `user #${ban.user_id}`}</p>
<p className="truncate text-[11px] text-slate-400">@{user?.username || "unknown"}</p>
</div>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px] hover:bg-slate-600"
onClick={async () => {
try {
await unbanChatMember(chatId, ban.user_id);
await refreshBans(chatId, false);
await refreshMembers(chatId);
showToast("User unbanned");
} catch {
setError("Failed to unban user");
}
}}
type="button"
>
Unban
</button>
</div>
);
})}
</div>
</div>
) : null}
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
Media & Files {attachments.length > 0 ? `(${attachments.length})` : ""}
</p>
<div className="sticky top-0 z-10 mb-2 flex items-center gap-2 border-b border-slate-700/60 bg-slate-800/90 pb-2 pt-1 text-xs backdrop-blur">
<button
className={`rounded px-2 py-1 ${attachmentsTab === "all" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("all")}
type="button"
>
All ({attachments.length})
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "photos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("photos")}
type="button"
>
Photos ({photoAttachments.length})
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "videos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("videos")}
type="button"
>
Videos ({videoAttachments.length})
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "audio" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("audio")}
type="button"
>
Audio ({audioAttachments.length})
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "voice" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("voice")}
type="button"
>
Voice ({voiceAttachments.length})
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "links" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("links")}
type="button"
>
Links ({linkItems.length})
</button>
</div>
{attachmentsLoading ? <p className="text-xs text-slate-400">Loading attachments...</p> : null}
{!attachmentsLoading && attachments.length === 0 ? <p className="text-xs text-slate-400">No attachments yet</p> : null}
{!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "photos" || attachmentsTab === "videos") ? (
<>
<div className="grid grid-cols-3 gap-1">
{(attachmentsTab === "photos" ? photoAttachments : attachmentsTab === "videos" ? videoAttachments : [...photoAttachments, ...videoAttachments])
.slice(0, 120)
.map((item) => (
<button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
key={`media-item-${item.id}`}
onClick={() => {
const mediaItems = [...photoAttachments, ...videoAttachments]
.sort((a, b) => b.id - a.id)
.map((it) => ({ url: it.file_url, type: it.file_type.startsWith("video/") ? "video" as const : "image" as const, messageId: it.message_id }));
const idx = mediaItems.findIndex((it) => it.url === item.file_url && it.messageId === item.message_id);
setMediaViewer({ items: mediaItems, index: idx >= 0 ? idx : 0 });
}}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url,
messageId: item.message_id,
});
}}
type="button"
>
{item.file_type.startsWith("video/") ? (
<video className="h-full w-full object-cover transition group-hover:scale-105" muted src={item.file_url} />
) : (
<img alt="attachment" className="h-full w-full object-cover transition group-hover:scale-105" src={item.file_url} />
)}
</button>
))}
</div>
</>
) : null}
{!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "audio" || attachmentsTab === "voice") ? (
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
{(attachmentsTab === "audio" ? audioAttachments : attachmentsTab === "voice" ? voiceAttachments : allAttachmentItems)
.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/"))
.slice(0, 120)
.map((item) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
key={`file-item-${item.id}`}
onClick={() => jumpToMessage(item.message_id)}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url,
messageId: item.message_id,
});
}}
type="button"
>
<p className="truncate text-xs font-semibold text-slate-200">{extractFileName(item.file_url)}</p>
<p className="text-[11px] text-slate-400">
{attachmentKind(item.file_type)} {formatBytes(item.file_size)}
</p>
</button>
))}
</div>
) : null}
{!attachmentsLoading && attachmentsTab === "links" ? (
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
{linkItems.length === 0 ? <p className="text-xs text-slate-400">No links found</p> : null}
{linkItems.map((item, index) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
key={`link-item-${item.messageId}-${index}`}
onClick={() => jumpToMessage(item.messageId)}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.url,
messageId: item.messageId,
});
}}
type="button"
>
<p className="truncate text-xs font-semibold text-sky-300">{shortLink(item.url)}</p>
<p className="truncate text-[11px] text-slate-400">{item.url}</p>
<p className="text-[11px] text-slate-500">{new Date(item.createdAt).toLocaleString()}</p>
</button>
))}
</div>
) : null}
</div>
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
<button
className="mb-3 w-full rounded bg-slate-700 px-3 py-2 text-sm disabled:opacity-60"
disabled={savingBlock}
onClick={async () => {
setSavingBlock(true);
try {
if (counterpartBlocked) {
await unblockUser(chat.counterpart_user_id!);
setCounterpartBlocked(false);
} else {
await blockUser(chat.counterpart_user_id!);
setCounterpartBlocked(true);
}
} catch {
setError("Failed to update block status");
} finally {
setSavingBlock(false);
}
}}
>
{counterpartBlocked ? "Unblock user" : "Block user"}
</button>
) : null}
{showMembersSection && (chat.type === "group" || chat.type === "channel") ? (
<button
className="w-full rounded bg-slate-700 px-3 py-2 text-sm"
onClick={async () => {
try {
await leaveChat(chatId);
await loadChats();
setActiveChatId(null);
onClose();
} catch {
setError("Failed to leave chat");
}
}}
>
{chat.type === "channel" ? "Leave channel" : "Leave chat"}
</button>
) : null}
</>
) : null}
</div>
</aside>
{attachmentCtx ? (
<div
className="fixed z-[130] w-44 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: attachmentCtx.x, top: attachmentCtx.y }}
onClick={(event) => event.stopPropagation()}
>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
Open
</a>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
Download
</a>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await navigator.clipboard.writeText(attachmentCtx.url);
showToast("Link copied");
} catch {
showToast("Copy failed");
return;
} finally {
setAttachmentCtx(null);
}
}}
type="button"
>
Copy link
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
jumpToMessage(attachmentCtx.messageId);
setAttachmentCtx(null);
}}
type="button"
>
Jump to message
</button>
</div>
) : null}
{memberCtx ? (
<div
className="fixed z-[130] w-52 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: memberCtx.x, top: memberCtx.y }}
onClick={(event) => event.stopPropagation()}
>
{myRoleNormalized === "owner" && memberCtx.member.role === "member" ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin");
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to update role");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Make admin
</button>
) : null}
{myRoleNormalized === "owner" && memberCtx.member.role === "admin" ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "member");
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to update role");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Remove admin rights
</button>
) : null}
{myRoleNormalized === "owner" && memberCtx.member.role !== "owner" ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner");
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to transfer ownership");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Transfer ownership
</button>
) : null}
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={async () => {
try {
await banChatMember(chatId, memberCtx.member.user_id);
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to ban member");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Ban from chat
</button>
) : null}
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={async () => {
try {
await removeChatMember(chatId, memberCtx.member.user_id);
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to remove member");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Remove from chat (without ban)
</button>
) : null}
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => setMemberCtx(null)}
type="button"
>
Cancel
</button>
</div>
) : null}
{mediaViewer ? (
<MediaViewer
index={mediaViewer.index}
items={mediaViewer.items}
onClose={() => setMediaViewer(null)}
onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
onJumpToMessage={(messageId) => jumpToMessage(messageId)}
onToast={showToast}
open
/>
) : null}
<AvatarCropModal
file={avatarCropFile}
onApply={(processedFile) => {
setAvatarCropFile(null);
void uploadChatAvatar(processedFile);
}}
onCancel={() => setAvatarCropFile(null)}
open={Boolean(avatarCropFile)}
/>
</div>,
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<Message[]> {
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<string>();
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;
}
}