diff --git a/src/App.tsx b/src/App.tsx index 5bbb3fe..324aaa3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,38 +1,26 @@ -import { FC, useCallback, useEffect } from 'react'; -import { Toaster, toast } from 'react-hot-toast'; -import { useTranslation } from 'react-i18next'; +import { FC } from 'react'; +import { Toaster } from 'react-hot-toast'; import { BrowserRouter, Navigate, Outlet, Route, Routes, - useNavigate, useParams, } from 'react-router'; import { Footer } from './components/Footer'; import Header from './components/Header'; import Sidebar from './components/Sidebar'; -import { ToastPopup } from './components/ToastPopup'; -import { useDebouncedCallback } from './hooks/useDebouncedCallback'; -import { usePWAUpdatePrompt } from './hooks/usePWAUpdatePrompt'; +import { useProviderSetup } from './hooks/useProviderSetup'; +import { usePWAUpdateToast } from './hooks/usePWAUpdatePrompt'; import ChatPage from './pages/Chat'; import SettingsPage from './pages/Settings'; import WelcomePage from './pages/Welcome'; -import { AppContextProvider, useAppContext } from './store/app'; +import { AppContextProvider } from './store/app'; import { ChatContextProvider } from './store/chat'; -import { - InferenceContextProvider, - useInferenceContext, -} from './store/inference'; +import { InferenceContextProvider } from './store/inference'; import { ModalProvider } from './store/modal'; -const DEBOUNCE_DELAY = 5000; -const TOAST_IDS = { - PROVIDER_SETUP: 'provider-setup', - PWA_UPDATE: 'pwa-update', -}; - const App: FC = () => { return ( @@ -58,77 +46,8 @@ const App: FC = () => { }; const AppLayout: FC = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const { config, showSettings } = useAppContext(); - const { models } = useInferenceContext(); - const { isNewVersion, handleUpdate } = usePWAUpdatePrompt(); - - const checkModelsAndShowToast = useCallback( - (showSettings: boolean, models: unknown[]) => { - if (showSettings) return; - if (Array.isArray(models) && models.length > 0) return; - - toast( - (toast) => { - const isInitialSetup = config.baseUrl === ''; - const popupConfig = isInitialSetup ? 'welcomePopup' : 'noModelsPopup'; - - return ( - navigate('/settings')} - title={t(`toast.${popupConfig}.title`)} - description={t(`toast.${popupConfig}.description`)} - note={t(`toast.${popupConfig}.note`)} - submitBtn={t(`toast.${popupConfig}.submitBtnLabel`)} - cancelBtn={t(`toast.${popupConfig}.cancelBtnLabel`)} - /> - ); - }, - { - id: TOAST_IDS.PROVIDER_SETUP, - duration: config.baseUrl === '' ? Infinity : 10000, - position: 'top-center', - } - ); - }, - [t, config.baseUrl, navigate] - ); - - const delayedNoModels = useDebouncedCallback( - checkModelsAndShowToast, - DEBOUNCE_DELAY - ); - - // Handle PWA updates - useEffect(() => { - if (isNewVersion) { - toast( - (toast) => ( - - ), - { - id: TOAST_IDS.PWA_UPDATE, - duration: Infinity, - position: 'top-center', - } - ); - } - }, [t, isNewVersion, handleUpdate]); - - // Handle model checking - useEffect(() => { - delayedNoModels(showSettings, models); - }, [showSettings, models, delayedNoModels]); + usePWAUpdateToast(); + useProviderSetup(); return ( <> diff --git a/src/components/ToastPopup.tsx b/src/components/ToastPopup.tsx index 6f17e21..6c2fee0 100644 --- a/src/components/ToastPopup.tsx +++ b/src/components/ToastPopup.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import toast, { Toast } from 'react-hot-toast'; +import toast from 'react-hot-toast'; import { Button } from './Button'; /** @@ -33,14 +33,22 @@ import { Button } from './Button'; * ``` */ export const ToastPopup: FC<{ - t: Toast; + toastId: string; title: string; description: string; note?: string; submitBtn: string; cancelBtn: string; onSubmit: () => Promise | void; -}> = ({ t, title, description, note, submitBtn, cancelBtn, onSubmit }) => ( +}> = ({ + toastId, + title, + description, + note, + submitBtn, + cancelBtn, + onSubmit, +}) => (

{title}

{description}

@@ -52,7 +60,7 @@ export const ToastPopup: FC<{
-
diff --git a/src/hooks/usePWAUpdatePrompt.ts b/src/hooks/usePWAUpdatePrompt.ts deleted file mode 100644 index 77a0abd..0000000 --- a/src/hooks/usePWAUpdatePrompt.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useRegisterSW } from 'virtual:pwa-register/react'; - -export function usePWAUpdatePrompt() { - const { - needRefresh: [isNewVersion], - updateServiceWorker, - } = useRegisterSW({ - onRegisteredSW(swUrl, swRegistration) { - console.debug('SW Registered:', swUrl, swRegistration); - }, - onRegisterError(error) { - console.error('SW registration error', error); - }, - }); - - const handleUpdate = async () => { - await updateServiceWorker(true); - }; - - return { isNewVersion, handleUpdate }; -} diff --git a/src/hooks/usePWAUpdatePrompt.tsx b/src/hooks/usePWAUpdatePrompt.tsx new file mode 100644 index 0000000..6edf29d --- /dev/null +++ b/src/hooks/usePWAUpdatePrompt.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { ToastPopup } from '../components/ToastPopup'; +import { useRegisterSW } from 'virtual:pwa-register/react'; + +export function usePWAUpdatePrompt() { + const { + needRefresh: [isNewVersion], + updateServiceWorker, + } = useRegisterSW({ + onRegisteredSW(swUrl, swRegistration) { + console.debug('SW Registered:', swUrl, swRegistration); + }, + onRegisterError(error) { + console.error('SW registration error', error); + }, + }); + + const handleUpdate = async () => { + await updateServiceWorker(true); + }; + + return { isNewVersion, handleUpdate }; +} + +export function usePWAUpdateToast() { + const { t } = useTranslation(); + const { isNewVersion, handleUpdate } = usePWAUpdatePrompt(); + + useEffect(() => { + if (!isNewVersion) return; + + toast( + (toast) => ( + + ), + { + id: 'pwa-update', + duration: Infinity, + position: 'top-center', + } + ); + }, [t, isNewVersion, handleUpdate]); +} diff --git a/src/hooks/useProviderSetup.tsx b/src/hooks/useProviderSetup.tsx new file mode 100644 index 0000000..a8092a1 --- /dev/null +++ b/src/hooks/useProviderSetup.tsx @@ -0,0 +1,55 @@ +import { useCallback, useEffect } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { ToastPopup } from '../components/ToastPopup'; +import { useAppContext } from '../store/app'; +import { useInferenceContext } from '../store/inference'; +import { useDebouncedCallback } from './useDebouncedCallback'; + +const DEBOUNCE_DELAY = 5000; + +export function useProviderSetup() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { config, showSettings } = useAppContext(); + const { models } = useInferenceContext(); + + const checkModelsAndShowToast = useCallback( + (showSettings: boolean, models: unknown[]) => { + if (showSettings) return; + if (Array.isArray(models) && models.length > 0) return; + + const isInitialSetup = config.baseUrl === ''; + const popupConfig = isInitialSetup ? 'welcomePopup' : 'noModelsPopup'; + toast( + (toast) => ( + navigate('/settings')} + title={t(`toast.${popupConfig}.title`)} + description={t(`toast.${popupConfig}.description`)} + note={t(`toast.${popupConfig}.note`)} + submitBtn={t(`toast.${popupConfig}.submitBtnLabel`)} + cancelBtn={t(`toast.${popupConfig}.cancelBtnLabel`)} + /> + ), + { + id: 'provider-setup', + duration: config.baseUrl === '' ? Infinity : 10000, + position: 'top-center', + } + ); + }, + [t, config.baseUrl, navigate] + ); + + const delayedNoModels = useDebouncedCallback( + checkModelsAndShowToast, + DEBOUNCE_DELAY + ); + + useEffect(() => { + delayedNoModels(showSettings, models); + }, [showSettings, models, delayedNoModels]); +} diff --git a/src/pages/Chat/components/ChatInput.tsx b/src/pages/Chat/components/ChatInput.tsx index 9ddd76a..975f11d 100644 --- a/src/pages/Chat/components/ChatInput.tsx +++ b/src/pages/Chat/components/ChatInput.tsx @@ -15,25 +15,9 @@ import SpeechToText, { } from '../../../hooks/useSpeechToText'; import { useChatContext } from '../../../store/chat'; import { MessageExtra } from '../../../types'; -import { cleanCurrentUrl } from '../../../utils'; +import { usePrefilledMessage } from '../hooks/usePrefilledMessage'; import { DropzoneArea } from './DropzoneArea'; -/** - * If the current URL contains "?m=...", prefill the message input with the value. - * If the current URL contains "?q=...", prefill and SEND the message. - */ -function getPrefilledContent() { - const searchParams = new URL(window.location.href).searchParams; - return searchParams.get('m') || searchParams.get('q') || ''; -} -function isPrefilledSend() { - const searchParams = new URL(window.location.href).searchParams; - return searchParams.has('q'); -} -function resetPrefilled() { - cleanCurrentUrl(['m', 'q']); -} - type CallbackSendMessage = ( content: string, extra: MessageExtra[] | undefined @@ -43,7 +27,8 @@ export const ChatInput = memo( ({ convId, onSend }: { convId?: string; onSend: CallbackSendMessage }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const textarea: ChatTextareaApi = useChatTextarea(getPrefilledContent()); + const { prefilledContent, isPrefilledSend } = usePrefilledMessage(); + const textarea: ChatTextareaApi = useChatTextarea(prefilledContent); const extraContext = useFileUpload(); const { isGenerating, stopGenerating } = useChatContext(); @@ -83,20 +68,17 @@ export const ChatInput = memo( useEffect(() => { // set textarea with prefilled value - const prefilled = getPrefilledContent(); - if (prefilled) { - textarea.setValue(prefilled); + if (prefilledContent) { + textarea.setValue(prefilledContent); } // send the prefilled message if needed // otherwise, focus on the input - if (isPrefilledSend()) sendNewMessage(); + if (isPrefilledSend) sendNewMessage(); else textarea.focus(); - // no need to keep track of sendNewMessage - resetPrefilled(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [window.location.href]); + }, [isPrefilledSend, prefilledContent]); return (
{ + const message = searchParams.get('m') || searchParams.get('q') || ''; + const send = searchParams.has('q'); + if (isDev) + console.debug('usePrefilledMessage', { + message, + send, + }); + + setPrefilledContent(message); + setIsPrefilledSend(send); + + // Clean up URL parameters after reading them + const cleanedSearchParams = new URLSearchParams(searchParams); + cleanedSearchParams.delete('m'); + cleanedSearchParams.delete('q'); + setSearchParams(cleanedSearchParams); + }, [searchParams, setSearchParams]); + + return { prefilledContent, isPrefilledSend }; +}