feat(web): improve chat moderation panel ux for members and bans
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -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; 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)
|
||||
25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban APIs for groups/channels including ban list endpoint; web Chat Info now shows searchable `Banned users` with right-click `Unban` action for owner/admin, plus member search and invite-link copy/regenerate actions; 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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user