1091 lines
46 KiB
TypeScript
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;
|
|
}
|
|
}
|