refactor IPC (#341)
This commit is contained in:
committed by
GitHub
Unverified
parent
c03d92e9a2
commit
3d804a9f5e
@@ -2,7 +2,7 @@
|
||||
* Channels Page
|
||||
* Manage messaging channel connections with configuration UI
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Radio,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} 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');
|
||||
@@ -54,17 +55,23 @@ export function Channels() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
|
||||
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
|
||||
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
|
||||
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
|
||||
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
|
||||
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
|
||||
// Fetch channels on mount
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
void fetchChannels({ probe: false });
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Fetch configured channel types from config file
|
||||
const fetchConfiguredTypes = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as {
|
||||
const result = await invokeIpc('channel:listConfigured') as {
|
||||
success: boolean;
|
||||
channels?: string[];
|
||||
};
|
||||
@@ -77,29 +84,86 @@ export function Channels() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchConfiguredTypes();
|
||||
}, [fetchConfiguredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
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<string>(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 = channels.filter((c) => c.status === 'connected').length;
|
||||
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
|
||||
|
||||
if (loading) {
|
||||
if (loading && channels.length === 0) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
@@ -118,8 +182,20 @@ export function Channels() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchChannels}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setRefreshing(true);
|
||||
await fetchChannels({ probe: true, silent: true });
|
||||
await fetchConfiguredTypes();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2${refreshing ? ' animate-spin' : ''}`} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
@@ -138,7 +214,7 @@ export function Channels() {
|
||||
<Radio className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{channels.length}</p>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +240,7 @@ export function Channels() {
|
||||
<PowerOff className="h-6 w-6 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,7 +249,7 @@ export function Channels() {
|
||||
</div>
|
||||
|
||||
{/* Gateway Warning */}
|
||||
{gatewayStatus.state !== 'running' && (
|
||||
{showGatewayWarning && (
|
||||
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||
@@ -194,7 +270,7 @@ export function Channels() {
|
||||
)}
|
||||
|
||||
{/* Configured Channels */}
|
||||
{channels.length > 0 && (
|
||||
{configuredChannels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('configured')}</CardTitle>
|
||||
@@ -202,7 +278,7 @@ export function Channels() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
{configuredChannels.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
@@ -230,7 +306,7 @@ export function Channels() {
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{displayedChannelTypes.map((type) => {
|
||||
const meta = CHANNEL_META[type];
|
||||
const isConfigured = configuredTypes.includes(type);
|
||||
const isConfigured = configuredTypeSet.has(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
@@ -243,7 +319,7 @@ export function Channels() {
|
||||
<span className="text-3xl">{meta.icon}</span>
|
||||
<p className="font-medium mt-2">{meta.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{meta.description}
|
||||
{t(meta.description)}
|
||||
</p>
|
||||
{isConfigured && (
|
||||
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
|
||||
@@ -272,8 +348,12 @@ export function Channels() {
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
onChannelAdded={() => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 2200);
|
||||
setShowAddDialog(false);
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
@@ -282,14 +362,16 @@ export function Channels() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!channelToDelete}
|
||||
title={t('common.confirm', 'Confirm')}
|
||||
title={t('common:actions.confirm', 'Confirm')}
|
||||
message={t('deleteConfirm')}
|
||||
confirmLabel={t('common.delete', 'Delete')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (channelToDelete) {
|
||||
await deleteChannel(channelToDelete.id);
|
||||
await fetchConfiguredTypes();
|
||||
await fetchChannels({ probe: false, silent: true });
|
||||
setChannelToDelete(null);
|
||||
}
|
||||
}}
|
||||
@@ -355,7 +437,6 @@ interface AddChannelDialogProps {
|
||||
|
||||
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
|
||||
const { t } = useTranslation('channels');
|
||||
const { addChannel } = useChannelsStore();
|
||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
@@ -382,7 +463,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setChannelName('');
|
||||
setIsExistingConfig(false);
|
||||
// Ensure we clean up any pending QR session if switching away
|
||||
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -391,7 +472,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'channel:getFormValues',
|
||||
selectedType
|
||||
) as { success: boolean; values?: Record<string, string> };
|
||||
@@ -439,7 +520,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.success(t('toast.whatsappConnected'));
|
||||
const accountId = data?.accountId || channelName.trim() || 'default';
|
||||
try {
|
||||
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||
const saveResult = await invokeIpc(
|
||||
'channel:saveConfig',
|
||||
'whatsapp',
|
||||
{ enabled: true }
|
||||
@@ -452,15 +533,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
} catch (error) {
|
||||
console.error('Failed to save WhatsApp config:', error);
|
||||
}
|
||||
// Register the channel locally so it shows up immediately
|
||||
addChannel({
|
||||
type: 'whatsapp',
|
||||
name: channelName || 'WhatsApp',
|
||||
}).then(() => {
|
||||
// Restart gateway to pick up the new session
|
||||
window.electron.ipcRenderer.invoke('gateway:restart').catch(console.error);
|
||||
onChannelAdded();
|
||||
});
|
||||
// channel:saveConfig triggers main-process reload/restart handling.
|
||||
// UI state refresh is handled by parent onChannelAdded().
|
||||
onChannelAdded();
|
||||
};
|
||||
|
||||
const onError = (...args: unknown[]) => {
|
||||
@@ -480,9 +555,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
if (typeof removeSuccessListener === 'function') removeSuccessListener();
|
||||
if (typeof removeErrorListener === 'function') removeErrorListener();
|
||||
// Cancel when unmounting or switching types
|
||||
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
};
|
||||
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
|
||||
}, [selectedType, channelName, onChannelAdded, t]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedType) return;
|
||||
@@ -491,7 +566,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
@@ -538,14 +613,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
// For QR-based channels, request QR code
|
||||
if (meta.connectionType === 'qr') {
|
||||
const accountId = channelName.trim() || 'default';
|
||||
await window.electron.ipcRenderer.invoke('channel:requestWhatsAppQr', accountId);
|
||||
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 window.electron.ipcRenderer.invoke(
|
||||
const validationResponse = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
@@ -592,7 +667,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
// Step 2: Save channel configuration via IPC
|
||||
const config: Record<string, unknown> = { ...configValues };
|
||||
const saveResult = await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedType, config) as {
|
||||
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
@@ -605,20 +680,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.warning(saveResult.warning);
|
||||
}
|
||||
|
||||
// Step 3: Add a local channel entry for the UI
|
||||
await addChannel({
|
||||
type: selectedType,
|
||||
name: channelName || CHANNEL_NAMES[selectedType],
|
||||
token: configValues[meta.configFields[0]?.key] || undefined,
|
||||
});
|
||||
// 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 restart is now handled server-side via debouncedRestart()
|
||||
// inside the channel:saveConfig IPC handler, so we don't need to
|
||||
// trigger it explicitly here. This avoids cascading restarts when
|
||||
// multiple config changes happen in quick succession (e.g. during
|
||||
// the setup wizard).
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user