From 5ae86feb2b0cf898899e4b86fb6cb06f187ba495 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:43:23 +0800 Subject: [PATCH] feat(desktop): add global channels store with zustand - Create ChannelsStore for global channel state management - Refactor useChannels hook to use the store - Initialize channels store at app startup - Share state between onboarding and channels page Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/renderer/src/App.tsx | 5 + .../src/renderer/src/hooks/use-channels.ts | 113 ++----------- .../src/renderer/src/stores/channels.ts | 154 ++++++++++++++++++ 3 files changed, 175 insertions(+), 97 deletions(-) create mode 100644 apps/desktop/src/renderer/src/stores/channels.ts diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 2e83ab90..bf706081 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -10,6 +10,8 @@ import ChannelsPage from './pages/channels' import CronsPage from './pages/crons' import OnboardingPage from './pages/onboarding' import { useOnboardingStore } from './stores/onboarding' +import { useProviderStore } from './stores/provider' +import { useChannelsStore } from './stores/channels' function OnboardingGuard({ children }: { children: React.ReactNode }) { const completed = useOnboardingStore((s) => s.completed) @@ -47,6 +49,9 @@ const router = createHashRouter([ export default function App() { useEffect(() => { useOnboardingStore.getState().initForceFlag() + // Prefetch global data at app startup + useProviderStore.getState().fetch() + useChannelsStore.getState().fetch() }, []) return ( diff --git a/apps/desktop/src/renderer/src/hooks/use-channels.ts b/apps/desktop/src/renderer/src/hooks/use-channels.ts index ba8ae386..724b3407 100644 --- a/apps/desktop/src/renderer/src/hooks/use-channels.ts +++ b/apps/desktop/src/renderer/src/hooks/use-channels.ts @@ -1,12 +1,10 @@ /** * Hook for managing channel accounts (Telegram, Discord, etc.) in the Desktop App. * - * Provides state and actions for the Channels settings page: - * - List channel account states (running / stopped / error) - * - Read channel config (tokens) - * - Save / remove tokens with immediate start/stop + * Uses the global ChannelsStore for state management. + * Data is fetched once at app startup and shared across all components. */ -import { useState, useEffect, useCallback } from 'react' +import { useChannelsStore } from '../stores/channels' export interface UseChannelsReturn { /** Runtime states of all channel accounts */ @@ -24,102 +22,23 @@ export interface UseChannelsReturn { /** Remove a bot token — stops the bot and removes from file */ removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> /** Stop a channel account without removing config */ - stopChannel: (channelId: string, accountId: string) => Promise + stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> /** Start a channel account from saved config */ - startChannel: (channelId: string, accountId: string) => Promise + startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> } export function useChannels(): UseChannelsReturn { - const [states, setStates] = useState([]) - const [config, setConfig] = useState> | undefined>>({}) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const refresh = useCallback(async () => { - setLoading(true) - setError(null) - - try { - const [stateList, channelConfig] = await Promise.all([ - window.electronAPI.channels.listStates(), - window.electronAPI.channels.getConfig(), - ]) - - setStates(stateList) - setConfig(channelConfig) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - console.error('[useChannels] Failed to load:', message) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - refresh() - }, [refresh]) - - const saveToken = useCallback(async (channelId: string, accountId: string, token: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.saveToken(channelId, accountId, token) - if (!result.ok) { - setError(result.error ?? 'Failed to save token') - } - // Refresh to pick up new state - await refresh() - return result - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - return { ok: false, error: message } - } - }, [refresh]) - - const removeToken = useCallback(async (channelId: string, accountId: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.removeToken(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to remove token') - } - await refresh() - return result - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - return { ok: false, error: message } - } - }, [refresh]) - - const stopChannel = useCallback(async (channelId: string, accountId: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.stop(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to stop channel') - } - await refresh() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - } - }, [refresh]) - - const startChannel = useCallback(async (channelId: string, accountId: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.start(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to start channel') - } - await refresh() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - } - }, [refresh]) + const { + states, + config, + loading, + error, + refresh, + saveToken, + removeToken, + stopChannel, + startChannel, + } = useChannelsStore() return { states, diff --git a/apps/desktop/src/renderer/src/stores/channels.ts b/apps/desktop/src/renderer/src/stores/channels.ts new file mode 100644 index 00000000..5242efbb --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/channels.ts @@ -0,0 +1,154 @@ +import { create } from 'zustand' + +interface ChannelsStore { + // State + states: ChannelAccountStateInfo[] + config: Record> | undefined> + loading: boolean + error: string | null + initialized: boolean + + // Actions + fetch: () => Promise + refresh: () => Promise + saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }> + removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> + stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> + startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> +} + +export const useChannelsStore = create()((set, get) => ({ + states: [], + config: {}, + loading: false, + error: null, + initialized: false, + + fetch: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + const [stateList, channelConfig] = await Promise.all([ + window.electronAPI.channels.listStates(), + window.electronAPI.channels.getConfig(), + ]) + + set({ + states: stateList, + config: channelConfig, + initialized: true, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ChannelsStore] Failed to load:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ loading: true, error: null }) + + try { + const [stateList, channelConfig] = await Promise.all([ + window.electronAPI.channels.listStates(), + window.electronAPI.channels.getConfig(), + ]) + + set({ + states: stateList, + config: channelConfig, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ChannelsStore] Failed to refresh:', message) + } finally { + set({ loading: false }) + } + }, + + saveToken: async (channelId: string, accountId: string, token: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.saveToken(channelId, accountId, token) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to save token' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, + + removeToken: async (channelId: string, accountId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.removeToken(channelId, accountId) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to remove token' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, + + stopChannel: async (channelId: string, accountId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.stop(channelId, accountId) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to stop channel' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, + + startChannel: async (channelId: string, accountId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.start(channelId, accountId) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to start channel' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, +}))