feat(web): improve chat moderation panel ux for members and bans
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
2026-03-08 21:31:07 +03:00
parent 775236b483
commit 4555a8454c
2 changed files with 135 additions and 22 deletions

View File

@@ -51,6 +51,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [chatAvatarUploading, setChatAvatarUploading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<UserSearchItem[]>([]);
const [memberFilter, setMemberFilter] = useState("");
const [bannedFilter, setBannedFilter] = useState("");
const [muted, setMuted] = useState(false);
const [savingMute, setSavingMute] = useState(false);
const [counterpartBlocked, setCounterpartBlocked] = useState(false);
@@ -61,6 +63,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
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 [banCtx, setBanCtx] = useState<{ x: number; y: number; ban: ChatBan } | 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");
@@ -99,6 +102,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
[attachments]
);
const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]);
const filteredMembers = useMemo(() => {
const query = memberFilter.trim().toLowerCase();
if (!query) {
return members;
}
return members.filter((member) => {
const user = memberUsers[member.user_id];
return (
(user?.name ?? "").toLowerCase().includes(query) ||
(user?.username ?? "").toLowerCase().includes(query) ||
String(member.user_id).includes(query)
);
});
}, [memberFilter, members, memberUsers]);
const filteredBans = useMemo(() => {
const query = bannedFilter.trim().toLowerCase();
if (!query) {
return bans;
}
return bans.filter((ban) => {
const user = bannedUsers[ban.user_id];
return (
(user?.name ?? "").toLowerCase().includes(query) ||
(user?.username ?? "").toLowerCase().includes(query) ||
String(ban.user_id).includes(query)
);
});
}, [bannedFilter, bans, bannedUsers]);
async function refreshMembers(targetChatId: number): Promise<ChatMember[]> {
const nextMembers = await listChatMembers(targetChatId);
@@ -239,6 +270,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setBannedUsers({});
setSearchQuery("");
setSearchResults([]);
setMemberFilter("");
setBannedFilter("");
}, [chatId, open]);
useEffect(() => {
@@ -279,6 +312,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
onClick={() => {
setAttachmentCtx(null);
setMemberCtx(null);
setBanCtx(null);
onClose();
}}
>
@@ -287,6 +321,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
onClick={(e) => {
setAttachmentCtx(null);
setMemberCtx(null);
setBanCtx(null);
e.stopPropagation();
}}
>
@@ -439,7 +474,42 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
>
Create invite link
</button>
{inviteLink ? <p className="mt-1 break-all text-[11px] text-sky-300">{inviteLink}</p> : null}
{inviteLink ? (
<>
<p className="mt-1 break-all text-[11px] text-sky-300">{inviteLink}</p>
<div className="mt-2 flex items-center gap-2">
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px] hover:bg-slate-600"
onClick={async () => {
try {
await navigator.clipboard.writeText(inviteLink);
showToast("Invite link copied");
} catch {
setError("Failed to copy invite link");
}
}}
type="button"
>
Copy
</button>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px] hover:bg-slate-600"
onClick={async () => {
try {
const link = await createInviteLink(chatId);
setInviteLink(link.invite_url);
showToast("Invite link regenerated");
} catch {
setError("Failed to regenerate invite link");
}
}}
type="button"
>
Regenerate
</button>
</div>
</>
) : null}
</div>
) : null}
</div>
@@ -447,8 +517,14 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
{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>
<input
className="mb-2 w-full rounded bg-slate-800 px-2 py-1.5 text-xs outline-none"
onChange={(e) => setMemberFilter(e.target.value)}
placeholder="Search members"
value={memberFilter}
/>
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
{members.map((member) => {
{filteredMembers.map((member) => {
const user = memberUsers[member.user_id];
const isSelf = member.user_id === me?.id;
const canOpenMemberMenu =
@@ -550,33 +626,37 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
{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>
<input
className="mb-2 w-full rounded bg-slate-800 px-2 py-1.5 text-xs outline-none"
onChange={(e) => setBannedFilter(e.target.value)}
placeholder="Search banned users"
value={bannedFilter}
/>
{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) => {
{filteredBans.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}`}>
<button
className="flex w-full items-center justify-between rounded border border-slate-700/60 bg-slate-900/60 px-2 py-1.5 text-left hover:bg-slate-800/70"
key={`ban-${ban.user_id}`}
onContextMenu={(event) => {
event.preventDefault();
setBanCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 210),
y: Math.min(event.clientY + 4, window.innerHeight - 130),
ban,
});
}}
type="button"
>
<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>
<p className="truncate text-[10px] text-slate-500">Right click for actions</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>
<span className="rounded bg-slate-700 px-2 py-1 text-[11px]">Banned</span>
</button>
);
})}
</div>
@@ -925,6 +1005,39 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</button>
</div>
) : null}
{banCtx ? (
<div
className="fixed z-[130] w-52 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: banCtx.x, top: banCtx.y }}
onClick={(event) => event.stopPropagation()}
>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await unbanChatMember(chatId, banCtx.ban.user_id);
await refreshBans(chatId, false);
await refreshMembers(chatId);
showToast("User unbanned");
} catch {
setError("Failed to unban user");
} finally {
setBanCtx(null);
}
}}
type="button"
>
Unban user
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => setBanCtx(null)}
type="button"
>
Cancel
</button>
</div>
) : null}
{mediaViewer ? (
<MediaViewer
index={mediaViewer.index}