Files
Messenger/web/src/components/ChatInfoPanel.tsx
benya 68ba97bb90
Some checks failed
CI / test (push) Failing after 20s
fix(web): unify mic/send button and restore scroll-down
- 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
2026-03-08 11:27:16 +03:00

909 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}