feat(web): add banned users section in chat info moderation
Some checks failed
CI / test (push) Failing after 2m12s
Some checks failed
CI / test (push) Failing after 2m12s
This commit is contained in:
@@ -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
|
## 3.4 Messages
|
||||||
|
|
||||||
### MessageRead
|
### MessageRead
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Legend:
|
|||||||
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { http } from "./http";
|
|||||||
import type {
|
import type {
|
||||||
Chat,
|
Chat,
|
||||||
ChatAttachment,
|
ChatAttachment,
|
||||||
|
ChatBan,
|
||||||
ChatDetail,
|
ChatDetail,
|
||||||
ChatInviteLink,
|
ChatInviteLink,
|
||||||
ChatMember,
|
ChatMember,
|
||||||
@@ -299,6 +300,11 @@ export async function listChatMembers(chatId: number): Promise<ChatMember[]> {
|
|||||||
return data;
|
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> {
|
export async function updateChatTitle(chatId: number, title: string): Promise<Chat> {
|
||||||
const { data } = await http.patch<Chat>(`/chats/${chatId}/title`, { title });
|
const { data } = await http.patch<Chat>(`/chats/${chatId}/title`, { title });
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export interface ChatMember {
|
|||||||
joined_at: string;
|
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 {
|
export interface ChatDetail extends Chat {
|
||||||
members: ChatMember[];
|
members: ChatMember[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import {
|
|||||||
getChatNotificationSettings,
|
getChatNotificationSettings,
|
||||||
getChatDetail,
|
getChatDetail,
|
||||||
leaveChat,
|
leaveChat,
|
||||||
|
listChatBans,
|
||||||
listChatMembers,
|
listChatMembers,
|
||||||
requestUploadUrl,
|
requestUploadUrl,
|
||||||
removeChatMember,
|
removeChatMember,
|
||||||
|
unbanChatMember,
|
||||||
updateChatProfile,
|
updateChatProfile,
|
||||||
updateChatNotificationSettings,
|
updateChatNotificationSettings,
|
||||||
updateChatMemberRole,
|
updateChatMemberRole,
|
||||||
uploadToPresignedUrl
|
uploadToPresignedUrl
|
||||||
} from "../api/chats";
|
} from "../api/chats";
|
||||||
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
|
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 { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { useUiStore } from "../store/uiStore";
|
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 [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 [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 [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 [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 [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null);
|
||||||
const [avatarCropFile, setAvatarCropFile] = useState<File | 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]);
|
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);
|
const nextMembers = await listChatMembers(targetChatId);
|
||||||
setMembers(nextMembers);
|
setMembers(nextMembers);
|
||||||
const ids = [...new Set(nextMembers.map((m) => m.user_id))];
|
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;
|
byId[profile.id] = profile;
|
||||||
}
|
}
|
||||||
setMemberUsers(byId);
|
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) {
|
async function refreshPanelData(targetChatId: number, withLoading = false) {
|
||||||
@@ -134,7 +159,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
setCounterpartProfile(null);
|
setCounterpartProfile(null);
|
||||||
setCounterpartBlocked(false);
|
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 chatAttachments = await getChatAttachments(targetChatId, 120);
|
||||||
const messages = await getRecentMessagesForLinks(targetChatId);
|
const messages = await getRecentMessagesForLinks(targetChatId);
|
||||||
setAttachments(chatAttachments);
|
setAttachments(chatAttachments);
|
||||||
@@ -202,6 +235,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
setInviteLink(null);
|
setInviteLink(null);
|
||||||
setMembers([]);
|
setMembers([]);
|
||||||
setMemberUsers({});
|
setMemberUsers({});
|
||||||
|
setBans([]);
|
||||||
|
setBannedUsers({});
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
}, [chatId, open]);
|
}, [chatId, open]);
|
||||||
@@ -512,6 +547,41 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<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">
|
<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 {
|
try {
|
||||||
await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin");
|
await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin");
|
||||||
await refreshMembers(chatId);
|
await refreshMembers(chatId);
|
||||||
|
await refreshBans(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to update role");
|
setError("Failed to update role");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -776,6 +847,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
try {
|
try {
|
||||||
await updateChatMemberRole(chatId, memberCtx.member.user_id, "member");
|
await updateChatMemberRole(chatId, memberCtx.member.user_id, "member");
|
||||||
await refreshMembers(chatId);
|
await refreshMembers(chatId);
|
||||||
|
await refreshBans(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to update role");
|
setError("Failed to update role");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -794,6 +866,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
try {
|
try {
|
||||||
await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner");
|
await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner");
|
||||||
await refreshMembers(chatId);
|
await refreshMembers(chatId);
|
||||||
|
await refreshBans(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to transfer ownership");
|
setError("Failed to transfer ownership");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -812,6 +885,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
try {
|
try {
|
||||||
await banChatMember(chatId, memberCtx.member.user_id);
|
await banChatMember(chatId, memberCtx.member.user_id);
|
||||||
await refreshMembers(chatId);
|
await refreshMembers(chatId);
|
||||||
|
await refreshBans(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to ban member");
|
setError("Failed to ban member");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -830,6 +904,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
try {
|
try {
|
||||||
await removeChatMember(chatId, memberCtx.member.user_id);
|
await removeChatMember(chatId, memberCtx.member.user_id);
|
||||||
await refreshMembers(chatId);
|
await refreshMembers(chatId);
|
||||||
|
await refreshBans(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to remove member");
|
setError("Failed to remove member");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user