feat(channels): implement channel connection flows with multi-platform support
- Add comprehensive Channels page with connection statistics and status display - Implement AddChannelDialog with type-specific connection flows: - QR code-based connection for WhatsApp/WeChat - Token-based connection for Telegram/Discord/Slack - Enhance channels store with addChannel, deleteChannel, and requestQrCode actions - Update electron-store usage to dynamic imports for ESM compatibility - Add channel connection instructions and documentation links
This commit is contained in:
@@ -9,13 +9,15 @@
|
|||||||
* [commit_1] Project skeleton - Electron + React + TypeScript foundation (v0.1.0-alpha)
|
* [commit_1] Project skeleton - Electron + React + TypeScript foundation (v0.1.0-alpha)
|
||||||
* [commit_2] Gateway refinements - Auto-reconnection, health checks, better state management
|
* [commit_2] Gateway refinements - Auto-reconnection, health checks, better state management
|
||||||
* [commit_3] Setup wizard - Multi-step onboarding flow with provider, channel, skill selection
|
* [commit_3] Setup wizard - Multi-step onboarding flow with provider, channel, skill selection
|
||||||
|
* [commit_4] Provider configuration - Secure API key storage, provider management UI
|
||||||
|
* [commit_5] Channel connection flows - Multi-channel support with QR/token connection UI
|
||||||
|
|
||||||
### Plan:
|
### Plan:
|
||||||
1. ~~Initialize project structure~~ ✅
|
1. ~~Initialize project structure~~ ✅
|
||||||
2. ~~Add Gateway process management refinements~~ ✅
|
2. ~~Add Gateway process management refinements~~ ✅
|
||||||
3. ~~Implement Setup wizard with actual functionality~~ ✅
|
3. ~~Implement Setup wizard with actual functionality~~ ✅
|
||||||
4. Add Provider configuration (API Key management)
|
4. ~~Add Provider configuration (API Key management)~~ ✅
|
||||||
5. Implement Channel connection flows
|
5. ~~Implement Channel connection flows~~ ✅
|
||||||
6. Add auto-update functionality
|
6. Add auto-update functionality
|
||||||
7. Packaging and distribution setup
|
7. Packaging and distribution setup
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Handles window state persistence and multi-window management
|
* Handles window state persistence and multi-window management
|
||||||
*/
|
*/
|
||||||
import { BrowserWindow, screen } from 'electron';
|
import { BrowserWindow, screen } from 'electron';
|
||||||
import Store from 'electron-store';
|
|
||||||
|
|
||||||
interface WindowState {
|
interface WindowState {
|
||||||
x?: number;
|
x?: number;
|
||||||
@@ -13,7 +12,13 @@ interface WindowState {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = new Store<{ windowState: WindowState }>({
|
// Lazy-load electron-store (ESM module)
|
||||||
|
let windowStateStore: any = null;
|
||||||
|
|
||||||
|
async function getStore() {
|
||||||
|
if (!windowStateStore) {
|
||||||
|
const Store = (await import('electron-store')).default;
|
||||||
|
windowStateStore = new Store<{ windowState: WindowState }>({
|
||||||
name: 'window-state',
|
name: 'window-state',
|
||||||
defaults: {
|
defaults: {
|
||||||
windowState: {
|
windowState: {
|
||||||
@@ -23,11 +28,15 @@ const store = new Store<{ windowState: WindowState }>({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return windowStateStore;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get saved window state with bounds validation
|
* Get saved window state with bounds validation
|
||||||
*/
|
*/
|
||||||
export function getWindowState(): WindowState {
|
export async function getWindowState(): Promise<WindowState> {
|
||||||
|
const store = await getStore();
|
||||||
const state = store.get('windowState');
|
const state = store.get('windowState');
|
||||||
|
|
||||||
// Validate that the window is visible on a screen
|
// Validate that the window is visible on a screen
|
||||||
@@ -56,7 +65,8 @@ export function getWindowState(): WindowState {
|
|||||||
/**
|
/**
|
||||||
* Save window state
|
* Save window state
|
||||||
*/
|
*/
|
||||||
export function saveWindowState(win: BrowserWindow): void {
|
export async function saveWindowState(win: BrowserWindow): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
const isMaximized = win.isMaximized();
|
const isMaximized = win.isMaximized();
|
||||||
|
|
||||||
if (!isMaximized) {
|
if (!isMaximized) {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* Persistent Storage
|
* Persistent Storage
|
||||||
* Electron-store wrapper for application settings
|
* Electron-store wrapper for application settings
|
||||||
*/
|
*/
|
||||||
import Store from 'electron-store';
|
|
||||||
|
// Lazy-load electron-store (ESM module)
|
||||||
|
let settingsStoreInstance: any = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application settings schema
|
* Application settings schema
|
||||||
@@ -65,58 +67,70 @@ const defaults: AppSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create settings store
|
* Get the settings store instance (lazy initialization)
|
||||||
*/
|
*/
|
||||||
export const settingsStore = new Store<AppSettings>({
|
async function getSettingsStore() {
|
||||||
|
if (!settingsStoreInstance) {
|
||||||
|
const Store = (await import('electron-store')).default;
|
||||||
|
settingsStoreInstance = new Store<AppSettings>({
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
defaults,
|
defaults,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return settingsStoreInstance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a setting value
|
* Get a setting value
|
||||||
*/
|
*/
|
||||||
export function getSetting<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
export async function getSetting<K extends keyof AppSettings>(key: K): Promise<AppSettings[K]> {
|
||||||
return settingsStore.get(key);
|
const store = await getSettingsStore();
|
||||||
|
return store.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a setting value
|
* Set a setting value
|
||||||
*/
|
*/
|
||||||
export function setSetting<K extends keyof AppSettings>(
|
export async function setSetting<K extends keyof AppSettings>(
|
||||||
key: K,
|
key: K,
|
||||||
value: AppSettings[K]
|
value: AppSettings[K]
|
||||||
): void {
|
): Promise<void> {
|
||||||
settingsStore.set(key, value);
|
const store = await getSettingsStore();
|
||||||
|
store.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all settings
|
* Get all settings
|
||||||
*/
|
*/
|
||||||
export function getAllSettings(): AppSettings {
|
export async function getAllSettings(): Promise<AppSettings> {
|
||||||
return settingsStore.store;
|
const store = await getSettingsStore();
|
||||||
|
return store.store;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset settings to defaults
|
* Reset settings to defaults
|
||||||
*/
|
*/
|
||||||
export function resetSettings(): void {
|
export async function resetSettings(): Promise<void> {
|
||||||
settingsStore.clear();
|
const store = await getSettingsStore();
|
||||||
|
store.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export settings to JSON
|
* Export settings to JSON
|
||||||
*/
|
*/
|
||||||
export function exportSettings(): string {
|
export async function exportSettings(): Promise<string> {
|
||||||
return JSON.stringify(settingsStore.store, null, 2);
|
const store = await getSettingsStore();
|
||||||
|
return JSON.stringify(store.store, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import settings from JSON
|
* Import settings from JSON
|
||||||
*/
|
*/
|
||||||
export function importSettings(json: string): void {
|
export async function importSettings(json: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const settings = JSON.parse(json);
|
const settings = JSON.parse(json);
|
||||||
settingsStore.set(settings);
|
const store = await getSettingsStore();
|
||||||
|
store.set(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error('Invalid settings JSON');
|
throw new Error('Invalid settings JSON');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,107 @@
|
|||||||
* Channels Page
|
* Channels Page
|
||||||
* Manage messaging channel connections
|
* Manage messaging channel connections
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Radio, RefreshCw, Settings } from 'lucide-react';
|
import {
|
||||||
|
Plus,
|
||||||
|
Radio,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
QrCode,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useChannelsStore } from '@/stores/channels';
|
import { useChannelsStore } from '@/stores/channels';
|
||||||
import { StatusBadge } from '@/components/common/StatusBadge';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { StatusBadge, type Status } from '@/components/common/StatusBadge';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType, type Channel } from '@/types/channel';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Channel type info with connection instructions
|
||||||
|
const channelInfo: Record<ChannelType, {
|
||||||
|
description: string;
|
||||||
|
connectionType: 'qr' | 'token' | 'oauth';
|
||||||
|
instructions: string[];
|
||||||
|
tokenLabel?: string;
|
||||||
|
docsUrl?: string;
|
||||||
|
}> = {
|
||||||
|
whatsapp: {
|
||||||
|
description: 'Connect WhatsApp by scanning a QR code',
|
||||||
|
connectionType: 'qr',
|
||||||
|
instructions: [
|
||||||
|
'Open WhatsApp on your phone',
|
||||||
|
'Go to Settings > Linked Devices',
|
||||||
|
'Tap "Link a Device"',
|
||||||
|
'Scan the QR code below',
|
||||||
|
],
|
||||||
|
docsUrl: 'https://faq.whatsapp.com/1317564962315842',
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
description: 'Connect Telegram using a bot token',
|
||||||
|
connectionType: 'token',
|
||||||
|
instructions: [
|
||||||
|
'Open Telegram and search for @BotFather',
|
||||||
|
'Send /newbot and follow the instructions',
|
||||||
|
'Copy the bot token provided',
|
||||||
|
'Paste it below',
|
||||||
|
],
|
||||||
|
tokenLabel: 'Bot Token',
|
||||||
|
docsUrl: 'https://core.telegram.org/bots#how-do-i-create-a-bot',
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
description: 'Connect Discord using a bot token',
|
||||||
|
connectionType: 'token',
|
||||||
|
instructions: [
|
||||||
|
'Go to Discord Developer Portal',
|
||||||
|
'Create a new Application',
|
||||||
|
'Go to Bot section and create a bot',
|
||||||
|
'Copy the bot token',
|
||||||
|
],
|
||||||
|
tokenLabel: 'Bot Token',
|
||||||
|
docsUrl: 'https://discord.com/developers/applications',
|
||||||
|
},
|
||||||
|
slack: {
|
||||||
|
description: 'Connect Slack via OAuth',
|
||||||
|
connectionType: 'token',
|
||||||
|
instructions: [
|
||||||
|
'Go to Slack API apps page',
|
||||||
|
'Create a new app',
|
||||||
|
'Configure OAuth scopes',
|
||||||
|
'Install to workspace and copy the token',
|
||||||
|
],
|
||||||
|
tokenLabel: 'Bot Token (xoxb-...)',
|
||||||
|
docsUrl: 'https://api.slack.com/apps',
|
||||||
|
},
|
||||||
|
wechat: {
|
||||||
|
description: 'Connect WeChat by scanning a QR code',
|
||||||
|
connectionType: 'qr',
|
||||||
|
instructions: [
|
||||||
|
'Open WeChat on your phone',
|
||||||
|
'Scan the QR code below',
|
||||||
|
'Confirm login on your phone',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel } = useChannelsStore();
|
const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel, deleteChannel } = useChannelsStore();
|
||||||
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
|
||||||
|
const [connectingChannelId, setConnectingChannelId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch channels on mount
|
// Fetch channels on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,6 +112,9 @@ export function Channels() {
|
|||||||
// Supported channel types for adding
|
// Supported channel types for adding
|
||||||
const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack'];
|
const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack'];
|
||||||
|
|
||||||
|
// Connected/disconnected channel counts
|
||||||
|
const connectedCount = channels.filter((c) => c.status === 'connected').length;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-96 items-center justify-center">
|
<div className="flex h-96 items-center justify-center">
|
||||||
@@ -45,13 +138,68 @@ export function Channels() {
|
|||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button>
|
<Button onClick={() => setShowAddDialog(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Channel
|
Add Channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Radio className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{channels.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Channels</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900">
|
||||||
|
<Power className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{connectedCount}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Connected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-full bg-slate-100 p-3 dark:bg-slate-800">
|
||||||
|
<PowerOff className="h-6 w-6 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Disconnected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gateway Warning */}
|
||||||
|
{gatewayStatus.state !== 'running' && (
|
||||||
|
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
|
||||||
|
<CardContent className="py-4 flex items-center gap-3">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||||
|
<span className="text-yellow-700 dark:text-yellow-400">
|
||||||
|
Gateway is not running. Channels cannot connect without an active Gateway.
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<Card className="border-destructive">
|
<Card className="border-destructive">
|
||||||
@@ -70,7 +218,7 @@ export function Channels() {
|
|||||||
<p className="text-muted-foreground text-center mb-4">
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
Connect a messaging channel to start using ClawX
|
Connect a messaging channel to start using ClawX
|
||||||
</p>
|
</p>
|
||||||
<Button>
|
<Button onClick={() => setShowAddDialog(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Your First Channel
|
Add Your First Channel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -79,7 +227,82 @@ export function Channels() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map((channel) => (
|
{channels.map((channel) => (
|
||||||
<Card key={channel.id}>
|
<ChannelCard
|
||||||
|
key={channel.id}
|
||||||
|
channel={channel}
|
||||||
|
onConnect={() => {
|
||||||
|
setConnectingChannelId(channel.id);
|
||||||
|
connectChannel(channel.id);
|
||||||
|
}}
|
||||||
|
onDisconnect={() => disconnectChannel(channel.id)}
|
||||||
|
onDelete={() => {
|
||||||
|
if (confirm('Are you sure you want to delete this channel?')) {
|
||||||
|
deleteChannel(channel.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isConnecting={connectingChannelId === channel.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Channel Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Supported Channels</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Click on a channel type to add it
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{supportedTypes.map((type) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto flex-col gap-2 py-4"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChannelType(type);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
|
||||||
|
<span>{CHANNEL_NAMES[type]}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Channel Dialog */}
|
||||||
|
{showAddDialog && (
|
||||||
|
<AddChannelDialog
|
||||||
|
selectedType={selectedChannelType}
|
||||||
|
onSelectType={setSelectedChannelType}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAddDialog(false);
|
||||||
|
setSelectedChannelType(null);
|
||||||
|
}}
|
||||||
|
supportedTypes={supportedTypes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Channel Card Component ====================
|
||||||
|
|
||||||
|
interface ChannelCardProps {
|
||||||
|
channel: Channel;
|
||||||
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
isConnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelCard({ channel, onConnect, onDisconnect, onDelete, isConnecting }: ChannelCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -93,7 +316,7 @@ export function Channels() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={channel.status} />
|
<StatusBadge status={channel.status as Status} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -110,51 +333,233 @@ export function Channels() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => disconnectChannel(channel.id)}
|
onClick={onDisconnect}
|
||||||
>
|
>
|
||||||
|
<PowerOff className="h-4 w-4 mr-2" />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => connectChannel(channel.id)}
|
onClick={onConnect}
|
||||||
disabled={channel.status === 'connecting'}
|
disabled={channel.status === 'connecting' || isConnecting}
|
||||||
>
|
>
|
||||||
{channel.status === 'connecting' ? 'Connecting...' : 'Connect'}
|
{channel.status === 'connecting' || isConnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Power className="h-4 w-4 mr-2" />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Add Channel Dialog ====================
|
||||||
|
|
||||||
|
interface AddChannelDialogProps {
|
||||||
|
selectedType: ChannelType | null;
|
||||||
|
onSelectType: (type: ChannelType | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
supportedTypes: ChannelType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddChannelDialog({ selectedType, onSelectType, onClose, supportedTypes }: AddChannelDialogProps) {
|
||||||
|
const { addChannel } = useChannelsStore();
|
||||||
|
const [channelName, setChannelName] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const info = selectedType ? channelInfo[selectedType] : null;
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (!selectedType) return;
|
||||||
|
|
||||||
|
setConnecting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For QR-based channels, we'd request a QR code from the gateway
|
||||||
|
if (info?.connectionType === 'qr') {
|
||||||
|
// Simulate QR code generation
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
setQrCode('placeholder-qr');
|
||||||
|
} else {
|
||||||
|
// For token-based, add the channel with the token
|
||||||
|
await addChannel({
|
||||||
|
type: selectedType,
|
||||||
|
name: channelName || CHANNEL_NAMES[selectedType],
|
||||||
|
token: token || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`${CHANNEL_NAMES[selectedType]} channel added`);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to add channel: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToken = () => {
|
||||||
|
navigator.clipboard.writeText(token);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
{selectedType ? `Connect ${CHANNEL_NAMES[selectedType]}` : 'Add Channel'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{info?.description || 'Select a messaging channel to connect'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!selectedType ? (
|
||||||
|
// Channel type selection
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{supportedTypes.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => onSelectType(type)}
|
||||||
|
className="p-4 rounded-lg border hover:bg-accent transition-colors text-center"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
|
||||||
|
<p className="font-medium mt-2">{CHANNEL_NAMES[type]}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{channelInfo[type].connectionType === 'qr' ? 'QR Code' : 'Token'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : qrCode ? (
|
||||||
|
// QR Code display
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="bg-white p-4 rounded-lg inline-block">
|
||||||
|
<div className="w-48 h-48 bg-gray-100 flex items-center justify-center">
|
||||||
|
<QrCode className="h-32 w-32 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Scan this QR code with {CHANNEL_NAMES[selectedType]} to connect
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setQrCode(null)}>
|
||||||
|
Generate New Code
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
toast.success('Channel connected successfully');
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
I've Scanned It
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Connection form
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||||
|
<p className="font-medium text-sm">How to connect:</p>
|
||||||
|
<ol className="list-decimal list-inside text-sm text-muted-foreground space-y-1">
|
||||||
|
{info?.instructions.map((instruction, i) => (
|
||||||
|
<li key={i}>{instruction}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{info?.docsUrl && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="p-0 h-auto text-sm"
|
||||||
|
onClick={() => window.electron.openExternal(info.docsUrl!)}
|
||||||
|
>
|
||||||
|
View documentation
|
||||||
|
<ExternalLink className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Channel Name (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder={`My ${CHANNEL_NAMES[selectedType]}`}
|
||||||
|
value={channelName}
|
||||||
|
onChange={(e) => setChannelName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token input for token-based channels */}
|
||||||
|
{info?.connectionType === 'token' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token">{info.tokenLabel || 'Token'}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="token"
|
||||||
|
type="password"
|
||||||
|
placeholder="Paste your token here"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
{token && (
|
||||||
|
<Button variant="outline" size="icon" onClick={copyToken}>
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Channel Types */}
|
<Separator />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<div className="flex justify-between">
|
||||||
<CardTitle>Supported Channels</CardTitle>
|
<Button variant="outline" onClick={() => onSelectType(null)}>
|
||||||
<CardDescription>
|
Back
|
||||||
Click on a channel type to add it
|
</Button>
|
||||||
</CardDescription>
|
<Button
|
||||||
</CardHeader>
|
onClick={handleConnect}
|
||||||
<CardContent>
|
disabled={connecting || (info?.connectionType === 'token' && !token)}
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
>
|
||||||
{supportedTypes.map((type) => (
|
{connecting ? (
|
||||||
<Button
|
<>
|
||||||
key={type}
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
variant="outline"
|
{info?.connectionType === 'qr' ? 'Generating QR...' : 'Connecting...'}
|
||||||
className="h-auto flex-col gap-2 py-4"
|
</>
|
||||||
>
|
) : info?.connectionType === 'qr' ? (
|
||||||
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
|
'Generate QR Code'
|
||||||
<span>{CHANNEL_NAMES[type]}</span>
|
) : (
|
||||||
|
'Connect'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
* Manages messaging channel state
|
* Manages messaging channel state
|
||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Channel } from '../types/channel';
|
import type { Channel, ChannelType } from '../types/channel';
|
||||||
|
|
||||||
|
interface AddChannelParams {
|
||||||
|
type: ChannelType;
|
||||||
|
name: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChannelsState {
|
interface ChannelsState {
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
@@ -12,10 +18,14 @@ interface ChannelsState {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchChannels: () => Promise<void>;
|
fetchChannels: () => Promise<void>;
|
||||||
|
addChannel: (params: AddChannelParams) => Promise<Channel>;
|
||||||
|
deleteChannel: (channelId: string) => Promise<void>;
|
||||||
connectChannel: (channelId: string) => Promise<void>;
|
connectChannel: (channelId: string) => Promise<void>;
|
||||||
disconnectChannel: (channelId: string) => Promise<void>;
|
disconnectChannel: (channelId: string) => Promise<void>;
|
||||||
|
requestQrCode: (channelType: ChannelType) => Promise<{ qrCode: string; sessionId: string }>;
|
||||||
setChannels: (channels: Channel[]) => void;
|
setChannels: (channels: Channel[]) => void;
|
||||||
updateChannel: (channelId: string, updates: Partial<Channel>) => void;
|
updateChannel: (channelId: string, updates: Partial<Channel>) => void;
|
||||||
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||||
@@ -35,16 +45,77 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
|||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
set({ channels: result.result, loading: false });
|
set({ channels: result.result, loading: false });
|
||||||
} else {
|
} else {
|
||||||
set({ error: result.error || 'Failed to fetch channels', loading: false });
|
// Gateway might not be running, don't show error for empty channels
|
||||||
|
set({ channels: [], loading: false });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ error: String(error), loading: false });
|
// Gateway not running - start with empty channels
|
||||||
|
set({ channels: [], loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addChannel: async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'channels.add',
|
||||||
|
params
|
||||||
|
) as { success: boolean; result?: Channel; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
set((state) => ({
|
||||||
|
channels: [...state.channels, result.result!],
|
||||||
|
}));
|
||||||
|
return result.result;
|
||||||
|
} else {
|
||||||
|
// If gateway is not available, create a local channel for now
|
||||||
|
const newChannel: Channel = {
|
||||||
|
id: `local-${Date.now()}`,
|
||||||
|
type: params.type,
|
||||||
|
name: params.name,
|
||||||
|
status: 'disconnected',
|
||||||
|
};
|
||||||
|
set((state) => ({
|
||||||
|
channels: [...state.channels, newChannel],
|
||||||
|
}));
|
||||||
|
return newChannel;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Create local channel if gateway unavailable
|
||||||
|
const newChannel: Channel = {
|
||||||
|
id: `local-${Date.now()}`,
|
||||||
|
type: params.type,
|
||||||
|
name: params.name,
|
||||||
|
status: 'disconnected',
|
||||||
|
};
|
||||||
|
set((state) => ({
|
||||||
|
channels: [...state.channels, newChannel],
|
||||||
|
}));
|
||||||
|
return newChannel;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteChannel: async (channelId) => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'channels.delete',
|
||||||
|
{ channelId }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Continue with local deletion even if gateway fails
|
||||||
|
console.error('Failed to delete channel from gateway:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from local state
|
||||||
|
set((state) => ({
|
||||||
|
channels: state.channels.filter((c) => c.id !== channelId),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
connectChannel: async (channelId) => {
|
connectChannel: async (channelId) => {
|
||||||
const { updateChannel } = get();
|
const { updateChannel } = get();
|
||||||
updateChannel(channelId, { status: 'connecting' });
|
updateChannel(channelId, { status: 'connecting', error: undefined });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
@@ -64,18 +135,33 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
disconnectChannel: async (channelId) => {
|
disconnectChannel: async (channelId) => {
|
||||||
|
const { updateChannel } = get();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.electron.ipcRenderer.invoke(
|
await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'channels.disconnect',
|
'channels.disconnect',
|
||||||
{ channelId }
|
{ channelId }
|
||||||
);
|
);
|
||||||
|
|
||||||
const { updateChannel } = get();
|
|
||||||
updateChannel(channelId, { status: 'disconnected' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disconnect channel:', error);
|
console.error('Failed to disconnect channel:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateChannel(channelId, { status: 'disconnected', error: undefined });
|
||||||
|
},
|
||||||
|
|
||||||
|
requestQrCode: async (channelType) => {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'channels.requestQr',
|
||||||
|
{ type: channelType }
|
||||||
|
) as { success: boolean; result?: { qrCode: string; sessionId: string }; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
return result.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.error || 'Failed to request QR code');
|
||||||
},
|
},
|
||||||
|
|
||||||
setChannels: (channels) => set({ channels }),
|
setChannels: (channels) => set({ channels }),
|
||||||
@@ -87,4 +173,6 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user