Some checks failed
CI / test (push) Failing after 20s
- show one action button in composer: mic when empty, send when text exists - add floating scroll-to-bottom button in message list - exclude non-text/media messages from Chat Info links list to avoid duplicates
909 lines
39 KiB
TypeScript
909 lines
39 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import {
|
||
addChatMember,
|
||
createInviteLink,
|
||
getChatAttachments,
|
||
getMessages,
|
||
getChatNotificationSettings,
|
||
getChatDetail,
|
||
leaveChat,
|
||
listChatMembers,
|
||
removeChatMember,
|
||
updateChatNotificationSettings,
|
||
updateChatMemberRole,
|
||
updateChatTitle
|
||
} from "../api/chats";
|
||
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
|
||
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types";
|
||
import { useAuthStore } from "../store/authStore";
|
||
import { useChatStore } from "../store/chatStore";
|
||
import { useUiStore } from "../store/uiStore";
|
||
|
||
interface Props {
|
||
chatId: number | null;
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||
const me = useAuthStore((s) => s.me);
|
||
const loadChats = useChatStore((s) => s.loadChats);
|
||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||
const showToast = useUiStore((s) => s.showToast);
|
||
const [chat, setChat] = useState<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 [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 [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all");
|
||
const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null);
|
||
|
||
const myRole = useMemo(() => {
|
||
if (chat?.my_role) {
|
||
return chat.my_role;
|
||
}
|
||
return members.find((m) => m.user_id === me?.id)?.role;
|
||
}, [chat?.my_role, members, me?.id]);
|
||
const myRoleNormalized = useMemo(() => {
|
||
if (!myRole) {
|
||
return null;
|
||
}
|
||
const role = String(myRole).toLowerCase();
|
||
if (role === "owner" || role === "admin" || role === "member") {
|
||
return role;
|
||
}
|
||
return null;
|
||
}, [myRole]);
|
||
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
||
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
||
const canManageMembers = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
|
||
const canEditTitle = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
|
||
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]);
|
||
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
|
||
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
|
||
const audioAttachments = useMemo(
|
||
() =>
|
||
attachments
|
||
.filter((item) => item.message_type === "audio" || (item.file_type.startsWith("audio/") && item.message_type !== "voice"))
|
||
.sort((a, b) => b.id - a.id),
|
||
[attachments]
|
||
);
|
||
const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]);
|
||
|
||
async function refreshMembers(targetChatId: number) {
|
||
const nextMembers = await listChatMembers(targetChatId);
|
||
setMembers(nextMembers);
|
||
const ids = [...new Set(nextMembers.map((m) => m.user_id))];
|
||
const profiles = await Promise.all(ids.map((id) => getUserById(id)));
|
||
const byId: Record<number, AuthUser> = {};
|
||
for (const profile of profiles) {
|
||
byId[profile.id] = profile;
|
||
}
|
||
setMemberUsers(byId);
|
||
}
|
||
|
||
function jumpToMessage(messageId: number) {
|
||
if (!chatId) {
|
||
return;
|
||
}
|
||
setActiveChatId(chatId);
|
||
setFocusedMessage(chatId, messageId);
|
||
onClose();
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!open || !chatId) {
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setError(null);
|
||
setAttachmentsLoading(true);
|
||
void (async () => {
|
||
try {
|
||
const detail = await getChatDetail(chatId);
|
||
if (cancelled) return;
|
||
setChat(detail);
|
||
setTitleDraft(detail.title ?? "");
|
||
const notificationSettings = await getChatNotificationSettings(chatId);
|
||
if (!cancelled) {
|
||
setMuted(notificationSettings.muted);
|
||
}
|
||
if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) {
|
||
try {
|
||
const counterpart = await getUserById(detail.counterpart_user_id);
|
||
if (!cancelled) {
|
||
setCounterpartProfile(counterpart);
|
||
}
|
||
const blocked = await listBlockedUsers();
|
||
if (!cancelled) {
|
||
setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id));
|
||
}
|
||
} catch {
|
||
if (!cancelled) {
|
||
setCounterpartProfile(null);
|
||
setCounterpartBlocked(false);
|
||
}
|
||
}
|
||
} else if (!cancelled) {
|
||
setCounterpartProfile(null);
|
||
setCounterpartBlocked(false);
|
||
}
|
||
await refreshMembers(chatId);
|
||
const chatAttachments = await getChatAttachments(chatId, 120);
|
||
const messages = await getRecentMessagesForLinks(chatId);
|
||
if (!cancelled) {
|
||
setAttachments(chatAttachments);
|
||
setLinkItems(extractLinkItems(messages));
|
||
}
|
||
} catch {
|
||
if (!cancelled) setError("Failed to load chat info");
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoading(false);
|
||
setAttachmentsLoading(false);
|
||
}
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open, chatId]);
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
return;
|
||
}
|
||
setInviteLink(null);
|
||
setMembers([]);
|
||
setMemberUsers({});
|
||
setSearchQuery("");
|
||
setSearchResults([]);
|
||
}, [chatId, open]);
|
||
|
||
useEffect(() => {
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
onClose();
|
||
}
|
||
};
|
||
if (open) {
|
||
window.addEventListener("keydown", onKeyDown);
|
||
}
|
||
return () => window.removeEventListener("keydown", onKeyDown);
|
||
}, [open, onClose]);
|
||
|
||
if (!open || !chatId) {
|
||
return null;
|
||
}
|
||
|
||
return createPortal(
|
||
<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 ? (
|
||
<img alt="avatar" className="h-16 w-16 rounded-full object-cover" src={counterpartProfile.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);
|
||
} 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">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()}
|
||
onClick={async () => {
|
||
setSavingTitle(true);
|
||
try {
|
||
const updated = await updateChatTitle(chatId, 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}
|
||
|
||
<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");
|
||
}
|
||
}}
|
||
>
|
||
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 }}
|
||
>
|
||
<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);
|
||
} 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);
|
||
} 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);
|
||
} 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 removeChatMember(chatId, memberCtx.member.user_id);
|
||
await refreshMembers(chatId);
|
||
} catch {
|
||
setError("Failed to remove member");
|
||
} finally {
|
||
setMemberCtx(null);
|
||
}
|
||
}}
|
||
type="button"
|
||
>
|
||
Remove from chat
|
||
</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 ? (
|
||
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
|
||
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
||
<button className="absolute left-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs" onClick={() => setMediaViewer(null)} type="button">
|
||
Close
|
||
</button>
|
||
{mediaViewer.items.length > 1 ? (
|
||
<>
|
||
<button
|
||
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
|
||
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev))}
|
||
type="button"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
|
||
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev))}
|
||
type="button"
|
||
>
|
||
›
|
||
</button>
|
||
</>
|
||
) : null}
|
||
<button
|
||
className="absolute right-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs"
|
||
onClick={() => jumpToMessage(mediaViewer.items[mediaViewer.index].messageId)}
|
||
type="button"
|
||
>
|
||
Jump
|
||
</button>
|
||
{mediaViewer.items[mediaViewer.index].type === "image" ? (
|
||
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
|
||
) : (
|
||
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
function formatLastSeen(value: string): string {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "recently";
|
||
}
|
||
return date.toLocaleString(undefined, {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
});
|
||
}
|
||
|
||
function chatLabel(chat: { display_title?: string | null; title?: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string {
|
||
if (chat.display_title?.trim()) return chat.display_title;
|
||
if (chat.title?.trim()) return chat.title;
|
||
if (chat.is_saved) return "Saved Messages";
|
||
if (chat.type === "private") return "Direct chat";
|
||
if (chat.type === "group") return "Group";
|
||
return "Channel";
|
||
}
|
||
|
||
function privateChatStatusLabel(chat: { counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null }): string {
|
||
if (chat.counterpart_is_online) {
|
||
return "online";
|
||
}
|
||
if (chat.counterpart_last_seen_at) {
|
||
return `last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`;
|
||
}
|
||
return "offline";
|
||
}
|
||
|
||
function initialsFromName(value: string): string {
|
||
const prepared = value.trim();
|
||
if (!prepared) {
|
||
return "?";
|
||
}
|
||
const parts = prepared.split(/\s+/).filter(Boolean);
|
||
if (parts.length >= 2) {
|
||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||
}
|
||
return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
|
||
}
|
||
|
||
function formatBytes(size: number): string {
|
||
if (size < 1024) return `${size} B`;
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function extractFileName(url: string): string {
|
||
try {
|
||
const parsed = new URL(url);
|
||
const value = parsed.pathname.split("/").pop();
|
||
return decodeURIComponent(value || "file");
|
||
} catch {
|
||
const value = url.split("/").pop();
|
||
return value ? decodeURIComponent(value) : "file";
|
||
}
|
||
}
|
||
|
||
function attachmentKind(fileType: string): string {
|
||
if (fileType.startsWith("image/")) return "Photo";
|
||
if (fileType.startsWith("video/")) return "Video";
|
||
if (fileType.startsWith("audio/")) return "Audio";
|
||
if (fileType === "application/pdf") return "PDF";
|
||
return "File";
|
||
}
|
||
|
||
async function getRecentMessagesForLinks(chatId: number): Promise<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;
|
||
}
|
||
}
|