feat(phase2): bulk delete + audit log; update roadmap/parity docs

This commit is contained in:
2026-06-30 10:20:57 -04:00
parent d3b7f39b9e
commit d7c84647e5
12 changed files with 660 additions and 77 deletions
+63 -8
View File
@@ -114,9 +114,18 @@ export function ChatArea() {
const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
const bulkDeleteMessages = useMessageStore((s) => s.bulkDeleteMessages);
const selectedIds = useMessageStore((s) =>
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
);
const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth
const channels = activeServerId
? channelsByServer[activeServerId] || []
: [];
@@ -241,13 +250,22 @@ export function ChatArea() {
: "[NO CHANNEL SELECTED]"}
</span>
{activeChannelId && (
<button
onClick={() => setShowSearch((prev) => !prev)}
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
title="Search messages"
>
[SEARCH]
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectMode((prev) => !prev)}
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Toggle bulk delete selection"
>
[SELECT]
</button>
<button
onClick={() => setShowSearch((prev) => !prev)}
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
title="Search messages"
>
[SEARCH]
</button>
</div>
)}
</div>
{showSearch && activeChannelId && activeChannel && (
@@ -257,13 +275,50 @@ export function ChatArea() {
onClose={() => setShowSearch(false)}
/>
)}
{selectMode && activeChannelId && (
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
<span>{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
<button
onClick={() => clearSelectedMessages(activeChannelId)}
className="text-gb-fg-f hover:text-gb-orange"
>
[CLEAR]
</button>
<button
onClick={async () => {
if (!activeChannelId || selectedIds.size === 0) return;
if (!confirm(`Delete ${selectedIds.size} messages?`)) return;
try {
await bulkDeleteMessages(activeChannelId, Array.from(selectedIds));
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk delete failed');
}
}}
disabled={selectedIds.size === 0 || !canBulkDelete}
className="text-gb-red hover:text-gb-orange disabled:opacity-50"
>
[DELETE]
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
{messages.map((message) => (
<div key={message.id} className="break-words">
<div
key={message.id}
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
>
{selectMode && activeChannelId && (
<span className="text-gb-fg-f mr-2">
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
</span>
)}
<span className="text-gb-fg-f">
[{formatTime(message.created_at)}]
</span>{" "}
+14
View File
@@ -8,6 +8,7 @@ import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx';
import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string {
switch (status) {
@@ -38,6 +39,8 @@ export function Layout() {
const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [dmMode, setDmMode] = useState(false);
const [showServerSettings, setShowServerSettings] = useState(false);
const activeServerId = useServerStore((s) => s.activeServerId);
useEffect(() => {
fetchMe();
wsConnect();
@@ -101,6 +104,14 @@ export function Layout() {
)}
</div>
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
{activeServerId && (
<button
onClick={() => setShowServerSettings(true)}
className="terminal-button text-xs"
>
[SERVER SETTINGS]
</button>
)}
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
[LOGOUT]
</button>
@@ -146,6 +157,9 @@ export function Layout() {
</div>
{!dmMode && <MemberList />}
</div>
{showServerSettings && activeServerId && (
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
)}
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
<span>TERM v1.0</span>
<span>{new Date().toISOString().slice(0, 10)}</span>
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { api } from '../lib/api.ts';
import { useServerStore } from '../stores/server.ts';
interface ServerSettingsModalProps {
serverId: string;
onClose: () => void;
}
interface AuditEntry {
id: string;
server_id: string;
user_id: string | null;
username: string | null;
action_type: string;
target_type: string | null;
target_id: string | null;
reason: string | null;
changes: unknown;
created_at: string;
}
const ACTION_LABELS: Record<string, string> = {
KICK: 'kicked member',
BAN: 'banned member',
UNBAN: 'unbanned member',
MUTE: 'muted member',
UNMUTE: 'unmuted member',
ROLE_CREATE: 'created role',
ROLE_UPDATE: 'updated role',
ROLE_DELETE: 'deleted role',
MEMBER_ROLES_UPDATE: 'updated member roles',
CHANNEL_CREATE: 'created channel',
CHANNEL_UPDATE: 'updated channel',
CHANNEL_DELETE: 'deleted channel',
SERVER_UPDATE: 'updated server',
MESSAGE_DELETE: 'deleted message',
BULK_DELETE: 'bulk deleted messages',
};
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
const [tab, setTab] = useState<'audit'>('audit');
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const servers = useServerStore((s) => s.servers);
const server = servers.find((s) => s.id === serverId);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
if (tab !== 'audit') return;
setLoading(true);
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
.then((data) => setEntries(Array.isArray(data) ? data : []))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log'))
.finally(() => setLoading(false));
}, [tab, serverId]);
const formatTime = (iso: string) => {
const d = new Date(iso);
return d.toLocaleString();
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gb-bg border border-gb-bg-t w-[700px] max-h-[80vh] flex flex-col font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
<span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
<div className="flex border-b border-gb-bg-t">
<button
onClick={() => setTab('audit')}
className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[AUDIT LOG]
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{tab === 'audit' && (
<>
{loading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{error && <p className="text-gb-red text-xs">ERR: {error}</p>}
{!loading && !error && entries.length === 0 && (
<p className="text-gb-fg-f text-xs">[no audit log entries]</p>
)}
<div className="space-y-2">
{entries.map((entry) => (
<div key={entry.id} className="text-xs border border-gb-bg-t p-2 bg-gb-bg-s">
<div className="flex items-center justify-between text-gb-fg-f">
<span>{formatTime(entry.created_at)}</span>
<span className="text-gb-orange">{entry.action_type}</span>
</div>
<div className="text-gb-fg mt-1">
<span className="text-gb-aqua">{entry.username ?? entry.user_id ?? 'system'}</span>
{' '}<span className="text-gb-fg-s">{ACTION_LABELS[entry.action_type] ?? entry.action_type}</span>
{entry.target_id && (
<span className="text-gb-fg-f"> target:{entry.target_id}</span>
)}
</div>
{entry.reason && (
<div className="text-gb-fg-f mt-1">reason: {entry.reason}</div>
)}
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
);
}
+51 -1
View File
@@ -27,9 +27,10 @@ export interface SearchResultMessage extends Message {
channel_name?: string;
}
interface MessageState {
export interface MessageState {
messagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>;
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
isLoading: boolean;
error: string | null;
fetchMessages: (channelId: string, before?: string) => Promise<void>;
@@ -38,11 +39,15 @@ interface MessageState {
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (channelId: string, messageId: string) => void;
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
toggleSelectedMessage: (channelId: string, messageId: string) => void;
clearSelectedMessages: (channelId: string) => void;
}
export const useMessageStore = create<MessageState>((set) => ({
messagesByChannel: {},
searchResultsByChannel: {},
selectedMessageIds: {},
isLoading: false,
error: null,
@@ -140,4 +145,49 @@ export const useMessageStore = create<MessageState>((set) => ({
},
};
}),
bulkDeleteMessages: async (channelId, messageIds) => {
const res = await api.post<{ deleted: number }>(
`/channels/${channelId}/messages/bulk-delete`,
{ messages: messageIds },
);
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const ids = new Set(messageIds);
const selected = { ...state.selectedMessageIds };
delete selected[channelId];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list.filter((m) => !ids.has(m.id)),
},
selectedMessageIds: selected,
};
});
return res;
},
toggleSelectedMessage: (channelId, messageId) =>
set((state) => {
const current = state.selectedMessageIds[channelId] || new Set<string>();
const next = new Set(current);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
return {
selectedMessageIds: {
...state.selectedMessageIds,
[channelId]: next,
},
};
}),
clearSelectedMessages: (channelId) =>
set((state) => {
const next = { ...state.selectedMessageIds };
delete next[channelId];
return { selectedMessageIds: next };
}),
}));
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}