/** * Channels Page * Manage messaging channel connections with configuration UI */ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Plus, Radio, RefreshCw, Trash2, Power, PowerOff, QrCode, Loader2, X, ExternalLink, BookOpen, Eye, EyeOff, Check, AlertCircle, CheckCircle, ShieldCheck, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useChannelsStore } from '@/stores/channels'; import { useGatewayStore } from '@/stores/gateway'; import { StatusBadge, type Status } from '@/components/common/StatusBadge'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { CHANNEL_ICONS, CHANNEL_NAMES, CHANNEL_META, getPrimaryChannels, type ChannelType, type Channel, type ChannelMeta, type ChannelConfigField, } from '@/types/channel'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { invokeIpc } from '@/lib/api-client'; export function Channels() { const { t } = useTranslation('channels'); const { channels, loading, error, fetchChannels, deleteChannel } = useChannelsStore(); const gatewayStatus = useGatewayStore((state) => state.status); const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [configuredTypes, setConfiguredTypes] = useState([]); const [channelSnapshot, setChannelSnapshot] = useState([]); const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState([]); const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); const [refreshing, setRefreshing] = useState(false); const [showGatewayWarning, setShowGatewayWarning] = useState(false); const refreshDebounceRef = useRef | null>(null); const lastGatewayStateRef = useRef(gatewayStatus.state); // Fetch channels on mount useEffect(() => { void fetchChannels({ probe: false }); }, [fetchChannels]); // Fetch configured channel types from config file const fetchConfiguredTypes = useCallback(async () => { try { const result = await invokeIpc('channel:listConfigured') as { success: boolean; channels?: string[]; }; if (result.success && result.channels) { setConfiguredTypes(result.channels); } } catch { // ignore } }, []); useEffect(() => { void fetchConfiguredTypes(); }, [fetchConfiguredTypes]); useEffect(() => { const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => { if (refreshDebounceRef.current) { clearTimeout(refreshDebounceRef.current); } refreshDebounceRef.current = setTimeout(() => { void fetchChannels({ probe: false, silent: true }); void fetchConfiguredTypes(); }, 300); }); return () => { if (refreshDebounceRef.current) { clearTimeout(refreshDebounceRef.current); refreshDebounceRef.current = null; } if (typeof unsubscribe === 'function') { unsubscribe(); } }; }, [fetchChannels, fetchConfiguredTypes]); useEffect(() => { if (gatewayStatus.state === 'running') { setChannelSnapshot(channels); setConfiguredTypesSnapshot(configuredTypes); } }, [gatewayStatus.state, channels, configuredTypes]); useEffect(() => { const previousState = lastGatewayStateRef.current; const currentState = gatewayStatus.state; const justReconnected = currentState === 'running' && previousState !== 'running'; lastGatewayStateRef.current = currentState; if (!justReconnected) return; void fetchChannels({ probe: false, silent: true }); void fetchConfiguredTypes(); }, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]); // Delay warning to avoid flicker during expected short reload/restart windows. useEffect(() => { const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error'; const timer = setTimeout(() => { setShowGatewayWarning(shouldWarn); }, shouldWarn ? 1800 : 0); return () => clearTimeout(timer); }, [gatewayStatus.state]); // Get channel types to display const displayedChannelTypes = getPrimaryChannels(); const isGatewayTransitioning = gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting'; const channelsForView = isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels; const configuredTypesForView = isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes; // Single source of truth for configured status across cards, stats and badges. const configuredTypeSet = useMemo(() => { const set = new Set(configuredTypesForView); if (set.size === 0 && channelsForView.length > 0) { channelsForView.forEach((channel) => set.add(channel.type)); } return set; }, [configuredTypesForView, channelsForView]); const configuredChannels = useMemo( () => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)), [channelsForView, configuredTypeSet] ); // Connected/disconnected channel counts const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length; if (loading && channels.length === 0) { return (
); } return (
{/* Header */}

{t('title')}

{t('subtitle')}

{/* Stats */}

{configuredChannels.length}

{t('stats.total')}

{connectedCount}

{t('stats.connected')}

{configuredChannels.length - connectedCount}

{t('stats.disconnected')}

{/* Gateway Warning */} {showGatewayWarning && ( {t('gatewayWarning')} )} {/* Error Display */} {error && ( {error} )} {/* Configured Channels */} {configuredChannels.length > 0 && ( {t('configured')} {t('configuredDesc')}
{configuredChannels.map((channel) => ( setChannelToDelete({ id: channel.id })} /> ))}
)} {/* Available Channels */}
{t('available')} {t('availableDesc')}
{displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; const isConfigured = configuredTypeSet.has(type); return ( ); })}
{/* Add Channel Dialog */} {showAddDialog && ( { setShowAddDialog(false); setSelectedChannelType(null); }} onChannelAdded={() => { void fetchChannels({ probe: false, silent: true }); void fetchConfiguredTypes(); setTimeout(() => { void fetchChannels({ probe: false, silent: true }); void fetchConfiguredTypes(); }, 2200); setShowAddDialog(false); setSelectedChannelType(null); }} /> )} { if (channelToDelete) { await deleteChannel(channelToDelete.id); await fetchConfiguredTypes(); await fetchChannels({ probe: false, silent: true }); setChannelToDelete(null); } }} onCancel={() => setChannelToDelete(null)} />
); } // ==================== Channel Card Component ==================== interface ChannelCardProps { channel: Channel; onDelete: () => void; } function ChannelCard({ channel, onDelete }: ChannelCardProps) { return (
{CHANNEL_ICONS[channel.type]}
{channel.name} {CHANNEL_NAMES[channel.type]}
{channel.error && (

{channel.error}

)}
); } // ==================== Add Channel Dialog ==================== interface AddChannelDialogProps { selectedType: ChannelType | null; onSelectType: (type: ChannelType | null) => void; onClose: () => void; onChannelAdded: () => void; } function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) { const { t } = useTranslation('channels'); const [configValues, setConfigValues] = useState>({}); const [channelName, setChannelName] = useState(''); const [connecting, setConnecting] = useState(false); const [showSecrets, setShowSecrets] = useState>({}); const [qrCode, setQrCode] = useState(null); const [validating, setValidating] = useState(false); const [loadingConfig, setLoadingConfig] = useState(false); const [isExistingConfig, setIsExistingConfig] = useState(false); const firstInputRef = useRef(null); const [validationResult, setValidationResult] = useState<{ valid: boolean; errors: string[]; warnings: string[]; } | null>(null); const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; // Load existing config when a channel type is selected useEffect(() => { if (!selectedType) { setConfigValues({}); setChannelName(''); setIsExistingConfig(false); setChannelName(''); setIsExistingConfig(false); // Ensure we clean up any pending QR session if switching away invokeIpc('channel:cancelWhatsAppQr').catch(() => { }); return; } let cancelled = false; setLoadingConfig(true); (async () => { try { const result = await invokeIpc( 'channel:getFormValues', selectedType ) as { success: boolean; values?: Record }; if (cancelled) return; if (result.success && result.values && Object.keys(result.values).length > 0) { setConfigValues(result.values); setIsExistingConfig(true); } else { setConfigValues({}); setIsExistingConfig(false); } } catch { if (!cancelled) { setConfigValues({}); setIsExistingConfig(false); } } finally { if (!cancelled) setLoadingConfig(false); } })(); return () => { cancelled = true; }; }, [selectedType]); // Focus first input when form is ready (avoids Windows focus loss after native dialogs) useEffect(() => { if (selectedType && !loadingConfig && firstInputRef.current) { firstInputRef.current.focus(); } }, [selectedType, loadingConfig]); // Listen for WhatsApp QR events useEffect(() => { if (selectedType !== 'whatsapp') return; const onQr = (...args: unknown[]) => { const data = args[0] as { qr: string; raw: string }; setQrCode(`data:image/png;base64,${data.qr}`); }; const onSuccess = async (...args: unknown[]) => { const data = args[0] as { accountId?: string } | undefined; toast.success(t('toast.whatsappConnected')); const accountId = data?.accountId || channelName.trim() || 'default'; try { const saveResult = await invokeIpc( 'channel:saveConfig', 'whatsapp', { enabled: true } ) as { success?: boolean; error?: string }; if (!saveResult?.success) { console.error('Failed to save WhatsApp config:', saveResult?.error); } else { console.info('Saved WhatsApp config for account:', accountId); } } catch (error) { console.error('Failed to save WhatsApp config:', error); } // channel:saveConfig triggers main-process reload/restart handling. // UI state refresh is handled by parent onChannelAdded(). onChannelAdded(); }; const onError = (...args: unknown[]) => { const err = args[0] as string; console.error('WhatsApp Login Error:', err); toast.error(t('toast.whatsappFailed', { error: err })); setQrCode(null); setConnecting(false); }; const removeQrListener = window.electron.ipcRenderer.on('channel:whatsapp-qr', onQr); const removeSuccessListener = window.electron.ipcRenderer.on('channel:whatsapp-success', onSuccess); const removeErrorListener = window.electron.ipcRenderer.on('channel:whatsapp-error', onError); return () => { if (typeof removeQrListener === 'function') removeQrListener(); if (typeof removeSuccessListener === 'function') removeSuccessListener(); if (typeof removeErrorListener === 'function') removeErrorListener(); // Cancel when unmounting or switching types invokeIpc('channel:cancelWhatsAppQr').catch(() => { }); }; }, [selectedType, channelName, onChannelAdded, t]); const handleValidate = async () => { if (!selectedType) return; setValidating(true); setValidationResult(null); try { const result = await invokeIpc( 'channel:validateCredentials', selectedType, configValues ) as { success: boolean; valid?: boolean; errors?: string[]; warnings?: string[]; details?: Record; }; const warnings = result.warnings || []; if (result.valid && result.details) { const details = result.details; if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); if (details.guildName) warnings.push(`Server: ${details.guildName}`); if (details.channelName) warnings.push(`Channel: #${details.channelName}`); } setValidationResult({ valid: result.valid || false, errors: result.errors || [], warnings, }); } catch (error) { setValidationResult({ valid: false, errors: [String(error)], warnings: [], }); } finally { setValidating(false); } }; const handleConnect = async () => { if (!selectedType || !meta) return; setConnecting(true); setValidationResult(null); try { // For QR-based channels, request QR code if (meta.connectionType === 'qr') { const accountId = channelName.trim() || 'default'; await invokeIpc('channel:requestWhatsAppQr', accountId); // The QR code will be set via event listener return; } // Step 1: Validate credentials against the actual service API if (meta.connectionType === 'token') { const validationResponse = await invokeIpc( 'channel:validateCredentials', selectedType, configValues ) as { success: boolean; valid?: boolean; errors?: string[]; warnings?: string[]; details?: Record; }; if (!validationResponse.valid) { setValidationResult({ valid: false, errors: validationResponse.errors || ['Validation failed'], warnings: validationResponse.warnings || [], }); setConnecting(false); return; } // Show success details (bot name, guild name, etc.) as warnings/info const warnings = validationResponse.warnings || []; if (validationResponse.details) { const details = validationResponse.details; if (details.botUsername) { warnings.push(`Bot: @${details.botUsername}`); } if (details.guildName) { warnings.push(`Server: ${details.guildName}`); } if (details.channelName) { warnings.push(`Channel: #${details.channelName}`); } } // Show validation success with details setValidationResult({ valid: true, errors: [], warnings, }); } // Step 2: Save channel configuration via IPC const config: Record = { ...configValues }; const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as { success?: boolean; error?: string; warning?: string; pluginInstalled?: boolean; }; if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save channel config'); } if (typeof saveResult.warning === 'string' && saveResult.warning) { toast.warning(saveResult.warning); } // Step 3: Do not call channels.add from renderer; this races with // gateway reload/restart windows and can create stale local entries. toast.success(t('toast.channelSaved', { name: meta.name })); // Gateway reload/restart is handled in the main-process save handler. // Renderer should only persist config and refresh local UI state. toast.success(t('toast.channelConnecting', { name: meta.name })); // Brief delay so user can see the success state before dialog closes await new Promise((resolve) => setTimeout(resolve, 800)); onChannelAdded(); } catch (error) { toast.error(t('toast.configFailed', { error })); setConnecting(false); } }; const openDocs = () => { if (meta?.docsUrl) { const url = t(meta.docsUrl); try { if (window.electron?.openExternal) { window.electron.openExternal(url); } else { // Fallback: open in new window window.open(url, '_blank'); } } catch (error) { console.error('Failed to open docs:', error); // Fallback: open in new window window.open(url, '_blank'); } } }; const isFormValid = () => { if (!meta) return false; // Check all required fields are filled return meta.configFields .filter((field) => field.required) .every((field) => configValues[field.key]?.trim()); }; const updateConfigValue = (key: string, value: string) => { setConfigValues((prev) => ({ ...prev, [key]: value })); }; const toggleSecretVisibility = (key: string) => { setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); }; return (
{selectedType ? isExistingConfig ? t('dialog.updateTitle', { name: CHANNEL_NAMES[selectedType] }) : t('dialog.configureTitle', { name: CHANNEL_NAMES[selectedType] }) : t('dialog.addTitle')} {selectedType && isExistingConfig ? t('dialog.existingDesc') : meta ? t(meta.description) : t('dialog.selectDesc')}
{!selectedType ? ( // Channel type selection
{getPrimaryChannels().map((type) => { const channelMeta = CHANNEL_META[type]; return ( ); })}
) : qrCode ? ( // QR Code display
{qrCode.startsWith('data:image') ? ( Scan QR Code ) : (
)}

{t('dialog.scanQR', { name: meta?.name })}

) : loadingConfig ? ( // Loading saved config
{t('dialog.loadingConfig')}
) : ( // Connection form
{/* Existing config hint */} {isExistingConfig && (
{t('dialog.existingHint')}
)} {/* Instructions */}

{t('dialog.howToConnect')}

    {meta?.instructions.map((instruction, i) => (
  1. {t(instruction)}
  2. ))}
{/* Channel name */}
setChannelName(e.target.value)} />
{/* Configuration fields */} {meta?.configFields.map((field) => ( updateConfigValue(field.key, value)} showSecret={showSecrets[field.key] || false} onToggleSecret={() => toggleSecretVisibility(field.key)} /> ))} {/* Validation Results */} {validationResult && (
{validationResult.valid ? ( ) : ( )}

{validationResult.valid ? t('dialog.credentialsVerified') : t('dialog.validationFailed')}

{validationResult.errors.length > 0 && (
    {validationResult.errors.map((err, i) => (
  • {err}
  • ))}
)} {validationResult.valid && validationResult.warnings.length > 0 && (
{validationResult.warnings.map((info, i) => (

{info}

))}
)} {!validationResult.valid && validationResult.warnings.length > 0 && (

{t('dialog.warnings')}

    {validationResult.warnings.map((warn, i) => (
  • {warn}
  • ))}
)}
)}
{/* Validation Button - Only for token-based channels for now */} {meta?.connectionType === 'token' && ( )}
)}
); } // ==================== Config Field Component ==================== interface ConfigFieldProps { field: ChannelConfigField; value: string; onChange: (value: string) => void; showSecret: boolean; onToggleSecret: () => void; } function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) { const { t } = useTranslation('channels'); const isPassword = field.type === 'password'; return (
onChange(e.target.value)} className="font-mono text-sm" /> {isPassword && ( )}
{field.description && (

{t(field.description)}

)} {field.envVar && (

{t('dialog.envVar', { var: field.envVar })}

)}
); } export default Channels;