feat(web): add banned users section in chat info moderation
Some checks failed
CI / test (push) Failing after 2m12s

This commit is contained in:
2026-03-08 21:26:10 +03:00
parent 2f6aa86cc9
commit 775236b483
5 changed files with 103 additions and 4 deletions

View File

@@ -285,6 +285,17 @@ Rules:
}
```
### ChatBanRead
```json
{
"chat_id": 42,
"user_id": 101,
"banned_by_user_id": 5,
"created_at": "2026-03-10T00:00:00Z"
}
```
## 3.4 Messages
### MessageRead

View File

@@ -31,7 +31,7 @@ Legend:
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving)
23. Groups - `PARTIAL` (create/add/remove/invite link; join-by-invite and invite permissions covered by integration tests; members API now returns profile fields (`username/name/avatar_url`) for richer moderation UI; advanced moderation still partial)
24. Roles - `DONE` (owner/admin/member)
25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban APIs for groups/channels including ban list endpoint; integration tests cover channel member read-only, channel admin full-delete, channel message delete-for-all permissions, group profile edit permissions, owner-only role management rules, and admin-visible/member-forbidden ban-list access; remaining UX moderation tools limited)
25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban APIs for groups/channels including ban list endpoint; web Chat Info now shows `Banned users` with `Unban` action for owner/admin; integration tests cover channel member read-only, channel admin full-delete, channel message delete-for-all permissions, group profile edit permissions, owner-only role management rules, and admin-visible/member-forbidden ban-list access; remaining UX moderation tools limited)
26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; integration tests now also cover invite-link permissions (member forbidden, admin allowed); UX edge-cases still polishing)
27. Channel Types - `DONE` (public/private)
28. Notifications - `PARTIAL` (browser notifications + mute/settings; chat mute is propagated in chat list payload, honored by web realtime notifications with mention override, and mute toggle now syncs instantly in chat store; backend now emits `chat_updated` after notification mute/unmute for cross-tab consistency; no mobile push infra)

View File

@@ -2,6 +2,7 @@ import { http } from "./http";
import type {
Chat,
ChatAttachment,
ChatBan,
ChatDetail,
ChatInviteLink,
ChatMember,
@@ -299,6 +300,11 @@ export async function listChatMembers(chatId: number): Promise<ChatMember[]> {
return data;
}
export async function listChatBans(chatId: number): Promise<ChatBan[]> {
const { data } = await http.get<ChatBan[]>(`/chats/${chatId}/bans`);
return data;
}
export async function updateChatTitle(chatId: number, title: string): Promise<Chat> {
const { data } = await http.patch<Chat>(`/chats/${chatId}/title`, { title });
return data;

View File

@@ -48,6 +48,13 @@ export interface ChatMember {
joined_at: string;
}
export interface ChatBan {
chat_id: number;
user_id: number;
banned_by_user_id: number;
created_at: string;
}
export interface ChatDetail extends Chat {
members: ChatMember[];
}

View File

@@ -9,16 +9,18 @@ import {
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, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types";
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";
@@ -59,6 +61,8 @@ 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 [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);
@@ -96,7 +100,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
);
const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]);
async function refreshMembers(targetChatId: number) {
async function refreshMembers(targetChatId: number): Promise<ChatMember[]> {
const nextMembers = await listChatMembers(targetChatId);
setMembers(nextMembers);
const ids = [...new Set(nextMembers.map((m) => m.user_id))];
@@ -106,6 +110,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
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) {
@@ -134,7 +159,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setCounterpartProfile(null);
setCounterpartBlocked(false);
}
await refreshMembers(targetChatId);
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);
@@ -202,6 +235,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setInviteLink(null);
setMembers([]);
setMemberUsers({});
setBans([]);
setBannedUsers({});
setSearchQuery("");
setSearchResults([]);
}, [chatId, open]);
@@ -512,6 +547,41 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</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">
@@ -758,6 +828,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin");
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to update role");
} finally {
@@ -776,6 +847,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "member");
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to update role");
} finally {
@@ -794,6 +866,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner");
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to transfer ownership");
} finally {
@@ -812,6 +885,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
try {
await banChatMember(chatId, memberCtx.member.user_id);
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to ban member");
} finally {
@@ -830,6 +904,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
try {
await removeChatMember(chatId, memberCtx.member.user_id);
await refreshMembers(chatId);
await refreshBans(chatId);
} catch {
setError("Failed to remove member");
} finally {