feat: real guild/channel names from Discord + remove selectors in Messages/Analytics

- discord-gateway: add redis command handlers for guilds:list, guilds:text-channels, voice:channels
- backend: replace postgres synthetic names with redis commands to gateway (with fallback)
- frontend: remove guild/channel dropdowns from messages and analytics tabs
- frontend: auto-load all channels from monitor guild via guildId query param
- frontend: show guild name in messages/analytics headers instead of selector
- live tab: keeps guild/channel selectors with real discord names

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-02 11:02:54 +07:00
co-authored by Claude Opus 4.8
parent f1ddca5eee
commit 5f419a6f0f
7 changed files with 243 additions and 182 deletions
@@ -1,6 +1,6 @@
import Redis from "ioredis"; import Redis from "ioredis";
import { getPool } from "../../shared/database/index.js";
import { config } from "../../shared/config/index.js"; import { config } from "../../shared/config/index.js";
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js"; import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("voice.service"); const logger = createChildLogger("voice.service");
@@ -100,7 +100,10 @@ async function sendCommand<T = unknown>(
redis.on("message", handler); redis.on("message", handler);
redis redis
.publish("backend:command", JSON.stringify({ id, type, payload, replyChannel })) .publish(
"backend:command",
JSON.stringify({ id, type, payload, replyChannel }),
)
.catch(() => { .catch(() => {
clearTimeout(timer); clearTimeout(timer);
resolve(null); resolve(null);
@@ -118,9 +121,17 @@ async function readStatus<T>(key: string): Promise<T | null> {
} }
/** /**
* Get guilds from database (distinct guild_id from messages). * Get guilds — query from discord-gateway via Redis command for real names.
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
*/ */
export async function getGuilds(): Promise<Guild[]> { export async function getGuilds(): Promise<Guild[]> {
const fromGateway = await sendCommand<Guild[]>("guilds:list", {});
if (fromGateway && fromGateway.length > 0) return fromGateway;
// Fallback: Postgres with synthetic names
logger.warn(
"discord-gateway unreachable, falling back to Postgres for guilds",
);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`, `SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
@@ -134,9 +145,20 @@ export async function getGuilds(): Promise<Guild[]> {
} }
/** /**
* Get text channels from database (distinct channel_id for a guild). * Get text channels — query from discord-gateway via Redis command for real names.
* Falls back to database if gateway unreachable.
*/ */
export async function getTextChannels(guildId: string): Promise<Channel[]> { export async function getTextChannels(guildId: string): Promise<Channel[]> {
const fromGateway = await sendCommand<Channel[]>("guilds:text-channels", {
guildId,
});
if (fromGateway && fromGateway.length > 0) return fromGateway;
// Fallback: Postgres with synthetic names
logger.warn(
{ guildId },
"discord-gateway unreachable, falling back to Postgres for text channels",
);
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`, `SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
@@ -1,9 +1,9 @@
import Redis from "ioredis";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import type { VoiceController } from "../voice-recording/voiceController.js"; import Redis from "ioredis";
import { discordPlayer } from "../voice-recording/player.js";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { createChildLogger } from "../../shared/logger/logger.js"; import { createChildLogger } from "../../shared/logger/logger.js";
import { discordPlayer } from "../voice-recording/player.js";
import type { VoiceController } from "../voice-recording/voiceController.js";
const logger = createChildLogger("command-handler"); const logger = createChildLogger("command-handler");
@@ -125,6 +125,15 @@ export class CommandHandler {
case "voice:disconnect": case "voice:disconnect":
reply = await this.handleVoiceDisconnect(cmd); reply = await this.handleVoiceDisconnect(cmd);
break; break;
case "voice:channels":
reply = await this.handleVoiceChannels(cmd);
break;
case "guilds:list":
reply = await this.handleListGuilds(cmd);
break;
case "guilds:text-channels":
reply = await this.handleTextChannels(cmd);
break;
case "media:queue": case "media:queue":
reply = await this.handleMediaQueue(cmd); reply = await this.handleMediaQueue(cmd);
break; break;
@@ -148,7 +157,10 @@ export class CommandHandler {
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
logger.error({ commandId: cmd.id, error: message }, "Command execution failed"); logger.error(
{ commandId: cmd.id, error: message },
"Command execution failed",
);
reply = { reply = {
id: cmd.id, id: cmd.id,
success: false, success: false,
@@ -175,7 +187,12 @@ export class CommandHandler {
private async handleVoiceConnect(cmd: BackendCommand): Promise<CommandReply> { private async handleVoiceConnect(cmd: BackendCommand): Promise<CommandReply> {
if (!this.client || !this.voiceController) { if (!this.client || !this.voiceController) {
return { id: cmd.id, success: false, data: null, error: "Gateway not initialized" }; return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
} }
const guildId = String(cmd.payload.guildId ?? ""); const guildId = String(cmd.payload.guildId ?? "");
@@ -194,15 +211,124 @@ export class CommandHandler {
return { id: cmd.id, success: true, data: status }; return { id: cmd.id, success: true, data: status };
} }
private async handleVoiceDisconnect(cmd: BackendCommand): Promise<CommandReply> { private async handleVoiceDisconnect(
cmd: BackendCommand,
): Promise<CommandReply> {
if (!this.voiceController) { if (!this.voiceController) {
return { id: cmd.id, success: false, data: null, error: "Gateway not initialized" }; return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
} }
const status = await this.voiceController.disconnect(); const status = await this.voiceController.disconnect();
return { id: cmd.id, success: true, data: status }; return { id: cmd.id, success: true, data: status };
} }
private async handleVoiceChannels(
cmd: BackendCommand,
): Promise<CommandReply> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
if (!guildId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId is required",
};
}
try {
const guild = await this.client.guilds.fetch(guildId);
const channels = await guild.channels.fetch();
const voiceChannels = channels
.filter((c) => c?.type === "GUILD_VOICE")
.map((c) => ({
id: c.id,
name: c.name,
type: "voice" as const,
}));
return { id: cmd.id, success: true, data: voiceChannels };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
private async handleListGuilds(cmd: BackendCommand): Promise<CommandReply> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
try {
const guilds = this.client.guilds.cache.map((g) => ({
id: g.id,
name: g.name,
icon: g.iconURL() ?? null,
}));
return { id: cmd.id, success: true, data: guilds };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
private async handleTextChannels(cmd: BackendCommand): Promise<CommandReply> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
if (!guildId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId is required",
};
}
try {
const guild = await this.client.guilds.fetch(guildId);
const channels = await guild.channels.fetch();
const textChannels = channels
.filter((c) => c?.type === "GUILD_TEXT")
.map((c) => ({
id: c.id,
name: c.name,
type: "text" as const,
}));
return { id: cmd.id, success: true, data: textChannels };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
private async handleMediaQueue(_cmd: BackendCommand): Promise<CommandReply> { private async handleMediaQueue(_cmd: BackendCommand): Promise<CommandReply> {
// Media queueing is handled at a higher level (frontend / backend streams // Media queueing is handled at a higher level (frontend / backend streams
// audio directly). Log the request for now. // audio directly). Log the request for now.
@@ -235,7 +361,11 @@ export class CommandHandler {
}; };
} }
discordPlayer.setMusicVolume(volume); discordPlayer.setMusicVolume(volume);
return { id: cmd.id, success: true, data: { volume: discordPlayer.getMusicVolume() } }; return {
id: cmd.id,
success: true,
data: { volume: discordPlayer.getMusicVolume() },
};
} }
// ---- Status publishing ---- // ---- Status publishing ----
@@ -243,7 +373,12 @@ export class CommandHandler {
private publishVoiceStatus(): void { private publishVoiceStatus(): void {
const status: VoiceStatusPayload = this.voiceController const status: VoiceStatusPayload = this.voiceController
? this.voiceController.getStatus() ? this.voiceController.getStatus()
: { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null }; : {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
};
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status)); this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
} }
@@ -265,7 +400,8 @@ export class CommandHandler {
*/ */
private setKey(key: string, value: string): void { private setKey(key: string, value: string): void {
const redis = new Redis(config.REDIS_URL); const redis = new Redis(config.REDIS_URL);
redis.set(key, value) redis
.set(key, value)
.catch((err: unknown) => { .catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
logger.warn({ key, error: msg }, "Failed to update Redis status key"); logger.warn({ key, error: msg }, "Failed to update Redis status key");
+23 -50
View File
@@ -64,14 +64,13 @@ export default function App() {
const activeTab = uiState.activeTab || "live"; const activeTab = uiState.activeTab || "live";
const selectedVoiceGuild = const selectedVoiceGuild =
uiState.selectedVoiceGuild || uiState.selectedGuild || ""; uiState.selectedVoiceGuild || uiState.selectedGuild || "";
const selectedTextGuild =
monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || ""; // Resolve monitor guild name from the full guild list (has real names now)
const selectedTextChannel = uiState.selectedTextChannel || ""; const monitorGuildName = useMemo(
const monitorGuild = useMemo(
() => () =>
monitorGuildId monitorGuildId
? voice.guilds.find((g) => g.id === monitorGuildId) ? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
: undefined, : null,
[monitorGuildId, voice.guilds], [monitorGuildId, voice.guilds],
); );
@@ -97,7 +96,9 @@ export default function App() {
onMessageAnalyzed: (m) => onMessageAnalyzed: (m) =>
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])), messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
onAttachmentUploaded: () => onAttachmentUploaded: () =>
messages.fetchMessages(selectedTextChannel).catch(() => undefined), messages
.fetchMessages(monitorGuildId || undefined)
.catch(() => undefined),
onMediaState: (state) => media.setMediaState(state as MediaState), onMediaState: (state) => media.setMediaState(state as MediaState),
onVoiceRecordingUploaded: (d) => onVoiceRecordingUploaded: (d) =>
window.dispatchEvent( window.dispatchEvent(
@@ -107,43 +108,37 @@ export default function App() {
const transmit = useAudioTransmit(socket.socketRef); const transmit = useAudioTransmit(socket.socketRef);
// Load app config on mount
useEffect(() => { useEffect(() => {
getAppConfig() getAppConfig()
.then((c) => { .then((c) => {
if (c.monitorGuildId) { if (c.monitorGuildId) {
setMonitorGuildId(c.monitorGuildId); setMonitorGuildId(c.monitorGuildId);
patchUIState({
selectedTextGuild: c.monitorGuildId,
selectedAnalyticsGuild: c.monitorGuildId,
selectedTextChannel: "",
selectedAnalyticsChannel: "",
});
} }
}) })
.catch(() => undefined); .catch(() => undefined);
}, [patchUIState]); }, []);
// Load voice channels when guild changes (Live tab)
useEffect(() => { useEffect(() => {
if (selectedVoiceGuild) if (selectedVoiceGuild)
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]); }, [selectedVoiceGuild, voice.loadVoiceChannels]);
// Auto-fetch messages for the monitor guild (all channels)
useEffect(() => { useEffect(() => {
if (monitorGuildId) if (monitorGuildId)
voice.loadTextTargets(monitorGuildId).catch(() => undefined); messages.fetchMessages(monitorGuildId).catch(() => undefined);
}, [monitorGuildId, voice.loadTextTargets]); }, [monitorGuildId, messages.fetchMessages]);
useEffect(() => {
if (selectedTextChannel)
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}, [selectedTextChannel, messages.fetchMessages]);
// Periodic refetch — ensures dashboard stays in sync even if WS events were missed // Periodic refetch — keeps dashboard in sync even if WS events missed
useEffect(() => { useEffect(() => {
if (!selectedTextChannel) return; if (!monitorGuildId) return;
const interval = setInterval(() => { const interval = setInterval(() => {
messages.fetchMessages(selectedTextChannel).catch(() => undefined); messages.fetchMessages(monitorGuildId).catch(() => undefined);
}, 15_000); // every 15s (longer than WS, shorter than stale cache) }, 15_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [selectedTextChannel, messages.fetchMessages]); }, [monitorGuildId, messages.fetchMessages]);
return ( return (
<DashboardLayout <DashboardLayout
@@ -191,15 +186,8 @@ export default function App() {
) )
) : activeTab === "messages" ? ( ) : activeTab === "messages" ? (
<MessagesPanel <MessagesPanel
guilds={monitorGuild ? [monitorGuild] : []} guildName={monitorGuildName}
channels={voice.textChannels}
selectedGuild={selectedTextGuild}
selectedChannel={selectedTextChannel}
messages={messages.messages} messages={messages.messages}
onGuildChange={(id) =>
patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })
}
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
onReanalyze={messages.reanalyze} onReanalyze={messages.reanalyze}
onLoadMore={messages.loadMore} onLoadMore={messages.loadMore}
hasMore={messages.hasMore} hasMore={messages.hasMore}
@@ -218,23 +206,8 @@ export default function App() {
} }
> >
<AnalyticsPanel <AnalyticsPanel
guilds={monitorGuild ? [monitorGuild] : []} guildId={monitorGuildId}
channels={voice.textChannels} guildName={monitorGuildName}
selectedGuild={
uiState.selectedAnalyticsGuild || selectedTextGuild || ""
}
selectedChannel={
uiState.selectedAnalyticsChannel || selectedTextChannel || ""
}
onGuildChange={(id) =>
patchUIState({
selectedAnalyticsGuild: id,
selectedAnalyticsChannel: "",
})
}
onChannelChange={(id) =>
patchUIState({ selectedAnalyticsChannel: id })
}
/> />
</Suspense> </Suspense>
</AnalyticsErrorBoundary> </AnalyticsErrorBoundary>
@@ -1,5 +1,4 @@
import { Activity, BarChart3 } from "lucide-react"; import { Activity, BarChart3 } from "lucide-react";
import type { Channel, Guild } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils"; import { cn } from "../../../shared/lib/utils";
import { import {
Button, Button,
@@ -8,7 +7,6 @@ import {
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Select,
} from "../../../shared/ui"; } from "../../../shared/ui";
const TIME_RANGES = [ const TIME_RANGES = [
@@ -22,27 +20,17 @@ const TIME_RANGES = [
]; ];
interface ControlBarProps { interface ControlBarProps {
guilds: Guild[]; guildName: string | null;
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
hours: number; hours: number;
isFetching: boolean; isFetching: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onHoursChange: (hours: number) => void; onHoursChange: (hours: number) => void;
onRefresh: () => void; onRefresh: () => void;
} }
export function ControlBar({ export function ControlBar({
guilds, guildName,
channels,
selectedGuild,
selectedChannel,
hours, hours,
isFetching, isFetching,
onGuildChange,
onChannelChange,
onHoursChange, onHoursChange,
onRefresh, onRefresh,
}: ControlBarProps) { }: ControlBarProps) {
@@ -54,28 +42,19 @@ export function ControlBar({
Analisis Moderasi Analisis Moderasi
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Pantau statistik, tren topik, dan aktivitas user. {guildName ? (
<>
Pantau statistik, tren topik, dan aktivitas user di seluruh
channel{" "}
<span className="font-medium text-foreground">{guildName}</span>.
</>
) : (
"Pantau statistik, tren topik, dan aktivitas user."
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Pilih guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
className="min-w-[180px]"
/>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Semua channel"
options={[
{ value: "", label: "Semua channel" },
...channels.map((c) => ({ value: c.id, label: c.name })),
]}
className="min-w-[160px]"
/>
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5"> <div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
{TIME_RANGES.map((tr) => ( {TIME_RANGES.map((tr) => (
<button <button
@@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import type { Channel, Guild } from "../../shared/api/client";
import { ActivityChart } from "./components/ActivityChart"; import { ActivityChart } from "./components/ActivityChart";
import { ControlBar } from "./components/ControlBar"; import { ControlBar } from "./components/ControlBar";
import { Heatmap } from "./components/Heatmap"; import { Heatmap } from "./components/Heatmap";
@@ -11,26 +10,16 @@ import { ViolatorTable } from "./components/ViolatorTable";
import { useAnalytics } from "./hooks/useAnalytics"; import { useAnalytics } from "./hooks/useAnalytics";
interface AnalyticsPanelProps { interface AnalyticsPanelProps {
guilds: Guild[]; guildId: string;
channels: Channel[]; guildName: string | null;
selectedGuild: string;
selectedChannel: string;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
} }
export function AnalyticsPanel({ export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
guilds,
channels,
selectedGuild,
selectedChannel,
onGuildChange,
onChannelChange,
}: AnalyticsPanelProps) {
const [hours, setHours] = useState(24); const [hours, setHours] = useState(24);
const analytics = useAnalytics({ const analytics = useAnalytics({
guildId: selectedGuild, guildId,
channelId: selectedChannel || undefined, // No channelId — analytics for all channels in the guild
channelId: undefined,
hours, hours,
}); });
@@ -60,11 +49,11 @@ export function AnalyticsPanel({
); );
} }
if (!selectedGuild) { if (!guildId) {
return ( return (
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8"> <div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Pilih guild untuk melihat analitik. Menunggu konfigurasi guild...
</p> </p>
</div> </div>
); );
@@ -73,14 +62,9 @@ export function AnalyticsPanel({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<ControlBar <ControlBar
guilds={guilds} guildName={guildName}
channels={channels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
hours={hours} hours={hours}
isFetching={isFetching} isFetching={isFetching}
onGuildChange={onGuildChange}
onChannelChange={onChannelChange}
onHoursChange={setHours} onHoursChange={setHours}
onRefresh={() => { onRefresh={() => {
refresh(); refresh();
@@ -12,8 +12,6 @@ export function mergeMessages(
for (const message of incoming) { for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message }); byId.set(message.id, { ...byId.get(message.id), ...message });
} }
// Removed .slice(0, 200) cap — let the message list grow unbounded.
// Infinite scroll handles the data volume via cursor pagination.
return Array.from(byId.values()).sort( return Array.from(byId.values()).sort(
(a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id), (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
); );
@@ -26,26 +24,25 @@ export function useMessages() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null); const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const currentChannel = useRef<string | null>(null); const currentGuild = useRef<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => { const fetchMessages = useCallback(async (guildId?: string) => {
if (!channelId) { if (!guildId) {
setMessages([]); setMessages([]);
setCursor(null); setCursor(null);
setHasMore(false); setHasMore(false);
return []; return [];
} }
currentChannel.current = channelId; currentGuild.current = guildId;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: String(PAGE_SIZE), limit: String(PAGE_SIZE),
channelId, guildId,
}); });
const result = await listMessages(params); const result = await listMessages(params);
// Only update state if we're still on the same channel (avoid race conditions) if (currentGuild.current === guildId) {
if (currentChannel.current === channelId) {
setMessages(result.data); setMessages(result.data);
setCursor(result.nextCursor); setCursor(result.nextCursor);
setHasMore(!!result.nextCursor); setHasMore(!!result.nextCursor);
@@ -61,30 +58,23 @@ export function useMessages() {
}, []); }, []);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (!cursor || !currentChannel.current || loadingMore) return; if (!cursor || !currentGuild.current || loadingMore) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: String(PAGE_SIZE), limit: String(PAGE_SIZE),
channelId: currentChannel.current, guildId: currentGuild.current,
cursor, cursor,
}); });
const result = await listMessages(params); const result = await listMessages(params);
// Only update if still on the same channel setMessages((prev) => [...prev, ...result.data]);
if ( setCursor(result.nextCursor);
currentChannel.current === result.data[0]?.channel_id || setHasMore(!!result.nextCursor);
currentChannel.current
) {
setMessages((prev) => [...prev, ...result.data]);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
}
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
}, [cursor, loadingMore]); }, [cursor, loadingMore]);
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
const reanalyze = useCallback(async (id: string): Promise<void> => { const reanalyze = useCallback(async (id: string): Promise<void> => {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
@@ -1,16 +1,14 @@
import { Filter, Search, X } from "lucide-react"; import { Filter, Search, X } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Channel, Guild, MessageRecord } from "../../shared/api/client"; import type { MessageRecord } from "../../shared/api/client";
import { import {
Badge, Badge,
Button, Button,
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Input, Input,
Select,
Tabs, Tabs,
TabsContent, TabsContent,
TabsList, TabsList,
@@ -20,13 +18,8 @@ import { ImageGrid } from "./components/ImageGrid";
import { MessageFeed } from "./components/MessageFeed"; import { MessageFeed } from "./components/MessageFeed";
interface MessagesPanelProps { interface MessagesPanelProps {
guilds: Guild[]; guildName: string | null;
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
messages: MessageRecord[]; messages: MessageRecord[];
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onReanalyze: (id: string) => Promise<void>; onReanalyze: (id: string) => Promise<void>;
onLoadMore?: () => void; onLoadMore?: () => void;
hasMore?: boolean; hasMore?: boolean;
@@ -36,13 +29,8 @@ interface MessagesPanelProps {
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending"; type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
export function MessagesPanel({ export function MessagesPanel({
guilds, guildName,
channels,
selectedGuild,
selectedChannel,
messages, messages,
onGuildChange,
onChannelChange,
onReanalyze, onReanalyze,
onLoadMore, onLoadMore,
hasMore, hasMore,
@@ -63,11 +51,7 @@ export function MessagesPanel({
} }
setIsSearching(true); setIsSearching(true);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({ q: searchQuery, limit: "50" });
q: searchQuery,
...(selectedChannel && { channelId: selectedChannel }),
limit: "50",
});
const response = await fetch(`/api/analysis/search?${params}`); const response = await fetch(`/api/analysis/search?${params}`);
if (!response.ok) throw new Error("Search failed"); if (!response.ok) throw new Error("Search failed");
const data = await response.json(); const data = await response.json();
@@ -110,24 +94,19 @@ export function MessagesPanel({
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Message Source</CardTitle> <CardTitle>Messages</CardTitle>
<CardDescription> {guildName && (
Pick a guild and channel/thread to inspect captures. <p className="text-sm text-muted-foreground">
</CardDescription> Monitoring all text channels in{" "}
<span className="font-medium text-foreground">{guildName}</span>
</p>
)}
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent>
<Select <p className="text-xs text-muted-foreground">
value={selectedGuild} Messages are automatically captured from all text channels in the
onChange={(e) => onGuildChange(e.target.value)} monitored guild. Real-time updates arrive via WebSocket.
placeholder="Select text guild" </p>
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
/>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Select channel or thread"
options={channels.map((c) => ({ value: c.id, label: c.name }))}
/>
</CardContent> </CardContent>
</Card> </Card>
@@ -257,9 +236,7 @@ export function MessagesPanel({
emptyText={ emptyText={
showSearch showSearch
? "No messages found matching your search." ? "No messages found matching your search."
: selectedChannel : "No captures yet."
? "No captures yet."
: "Select a channel to view captures."
} }
onLoadMore={showSearch ? undefined : onLoadMore} onLoadMore={showSearch ? undefined : onLoadMore}
hasMore={showSearch ? false : hasMore} hasMore={showSearch ? false : hasMore}