From 2ce90d94c092521693bd28a450358015e5540bf3 Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Sun, 18 May 2025 18:02:21 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A6=20Refactored=20socket=20store=20in?= =?UTF-8?q?=20zustand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/components/App.tsx | 7 +- .../ComicDetail/AcquisitionPanel.tsx | 371 ++++++++---------- src/client/store/index.ts | 300 +++++++------- 3 files changed, 324 insertions(+), 354 deletions(-) diff --git a/src/client/components/App.tsx b/src/client/components/App.tsx index 882ee8d..c6f867b 100644 --- a/src/client/components/App.tsx +++ b/src/client/components/App.tsx @@ -1,10 +1,15 @@ -import React, { ReactElement } from "react"; +import React, { ReactElement, useEffect } from "react"; import { Outlet } from "react-router-dom"; import { Navbar2 } from "./shared/Navbar2"; import { ToastContainer } from "react-toastify"; import "../assets/scss/App.scss"; +import { useStore } from "../store"; export const App = (): ReactElement => { + useEffect(() => { + useStore.getState().getSocket("/"); // Connect to the base namespace + }, []); + return ( <> diff --git a/src/client/components/ComicDetail/AcquisitionPanel.tsx b/src/client/components/ComicDetail/AcquisitionPanel.tsx index d592f30..5ba8c24 100644 --- a/src/client/components/ComicDetail/AcquisitionPanel.tsx +++ b/src/client/components/ComicDetail/AcquisitionPanel.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, ReactElement, useEffect, useState } from "react"; +import React, { + useCallback, + ReactElement, + useEffect, + useRef, + useState, +} from "react"; import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; import { RootState, SearchInstance } from "threetwo-ui-typings"; import ellipsize from "ellipsize"; @@ -10,6 +16,7 @@ import { useShallow } from "zustand/react/shallow"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints"; +import type { Socket } from "socket.io-client"; interface IAcquisitionPanelProps { query: any; @@ -21,32 +28,65 @@ interface IAcquisitionPanelProps { export const AcquisitionPanel = ( props: IAcquisitionPanelProps, ): ReactElement => { - const { socketIOInstance } = useStore( - useShallow((state) => ({ - socketIOInstance: state.socketIOInstance, - })), - ); + const socketRef = useRef(); + const queryClient = useQueryClient(); - interface SearchData { - query: Pick & Partial>; - hub_urls: string[] | undefined | null; - priority: PriorityEnum; - } - interface SearchResult { - id: string; - // Add other properties as needed - slots: any; - type: any; - users: any; - name: string; - dupe: Boolean; - size: number; - } + const [dcppQuery, setDcppQuery] = useState({}); + const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]); + const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false); + const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({}); + const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({}); + + const { comicObjectId } = props; + const issueName = props.query.issue.name || ""; + const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); + + useEffect(() => { + const socket = useStore.getState().getSocket("manual"); + socketRef.current = socket; + + // --- Handlers --- + const handleResultAdded = ({ result }: any) => { + setAirDCPPSearchResults((prev) => + prev.some((r) => r.id === result.id) ? prev : [...prev, result], + ); + }; + + const handleResultUpdated = ({ result }: any) => { + setAirDCPPSearchResults((prev) => { + const idx = prev.findIndex((r) => r.id === result.id); + if (idx === -1) return prev; + if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev; + const next = [...prev]; + next[idx] = result; + return next; + }); + }; + + const handleSearchInitiated = (data: any) => { + setAirDCPPSearchInstance(data.instance); + }; + + const handleSearchesSent = (data: any) => { + setAirDCPPSearchInfo(data.searchInfo); + }; + + // --- Subscribe once --- + socket.on("searchResultAdded", handleResultAdded); + socket.on("searchResultUpdated", handleResultUpdated); + socket.on("searchInitiated", handleSearchInitiated); + socket.on("searchesSent", handleSearchesSent); + + return () => { + socket.off("searchResultAdded", handleResultAdded); + socket.off("searchResultUpdated", handleResultUpdated); + socket.off("searchInitiated", handleSearchInitiated); + socket.off("searchesSent", handleSearchesSent); + // if you want to fully close the socket: + // useStore.getState().disconnectSocket("/manual"); + }; + }, []); - const handleSearch = (searchQuery) => { - // Use the already connected socket instance to emit events - socketIOInstance.emit("initiateSearch", searchQuery); - }; const { data: settings, isLoading, @@ -59,9 +99,7 @@ export const AcquisitionPanel = ( method: "GET", }), }); - /** - * Get the hubs list from an AirDCPP Socket - */ + const { data: hubs } = useQuery({ queryKey: ["hubs"], queryFn: async () => @@ -74,24 +112,8 @@ export const AcquisitionPanel = ( }), enabled: !isEmpty(settings?.data.directConnect?.client?.host), }); - const { comicObjectId } = props; - const issueName = props.query.issue.name || ""; - const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); - const [dcppQuery, setDcppQuery] = useState({}); - const [airDCPPSearchResults, setAirDCPPSearchResults] = useState< - SearchResult[] - >([]); - const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false); - const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({}); - const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({}); - const queryClient = useQueryClient(); - - // Construct a AirDC++ query based on metadata inferred, upon component mount - // Pre-populate the search input with the search string, so that - // all the user has to do is hit "Search AirDC++" to perform a search useEffect(() => { - // AirDC++ search query const dcppSearchQuery = { query: { pattern: `${sanitizedIssueName.replace(/#/g, "")}`, @@ -101,67 +123,22 @@ export const AcquisitionPanel = ( priority: 5, }; setDcppQuery(dcppSearchQuery); - }, []); + }, [hubs, sanitizedIssueName]); - /** - * Method to perform a search via an AirDC++ websocket - * @param {SearchData} data - a SearchData query - * @param {any} ADCPPSocket - an intialized AirDC++ socket instance - */ const search = async (searchData: any) => { setAirDCPPSearchResults([]); - socketIOInstance.emit("call", "socket.search", { + socketRef.current?.emit("call", "socket.search", { query: searchData, + namespace: "/manual", config: { protocol: `ws`, - // hostname: `192.168.1.119:5600`, - hostname: `127.0.0.1:5600`, - username: `user`, - password: `pass`, + hostname: `192.168.1.119:5600`, + username: `admin`, + password: `password`, }, }); }; - socketIOInstance.on("searchResultAdded", ({ result }: any) => { - setAirDCPPSearchResults((previousState) => { - const exists = previousState.some((item) => result.id === item.id); - if (!exists) { - return [...previousState, result]; - } - return previousState; - }); - }); - - socketIOInstance.on("searchResultUpdated", ({ result }: any) => { - // ...update properties of the existing result in the UI - const bundleToUpdateIndex = airDCPPSearchResults?.findIndex( - (bundle) => bundle.id === result.id, - ); - const updatedState = [...airDCPPSearchResults]; - if (!isNil(difference(updatedState[bundleToUpdateIndex], result))) { - updatedState[bundleToUpdateIndex] = result; - } - setAirDCPPSearchResults((state) => [...state, ...updatedState]); - }); - - socketIOInstance.on("searchInitiated", (data) => { - setAirDCPPSearchInstance(data.instance); - }); - socketIOInstance.on("searchesSent", (data) => { - setAirDCPPSearchInfo(data.searchInfo); - }); - - /** - * Method to download a bundle associated with a search result from AirDC++ - * @param {Number} searchInstanceId - description - * @param {String} resultId - description - * @param {String} comicObjectId - description - * @param {String} name - description - * @param {Number} size - description - * @param {any} type - description - * @param {any} config - description - * @returns {void} - description - */ const download = async ( searchInstanceId: Number, resultId: String, @@ -171,7 +148,7 @@ export const AcquisitionPanel = ( type: any, config: any, ): Promise => { - socketIOInstance.emit( + socketRef.current?.emit( "call", "socket.download", { @@ -186,6 +163,7 @@ export const AcquisitionPanel = ( (data: any) => console.log(data), ); }; + const getDCPPSearchResults = async (searchQuery) => { const manualQuery = { query: { @@ -316,20 +294,20 @@ export const AcquisitionPanel = ( {/* AirDC++ results */}
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? ( -
- +
+
- - - @@ -337,118 +315,93 @@ export const AcquisitionPanel = ( {map( airDCPPSearchResults, - ({ dupe, type, name, id, slots, users, size }, idx) => { - return ( - - + {/* NAME */} + - - {flag} - - - ))} - - - - - - - {type.str} - - - - - - {slots.total} slots; {slots.free} free - - - - - - ); - }, + {/* ACTIONS */} + + + ), )}
Name + Type + Slots + Actions
-

- {type.id === "directory" ? ( - - ) : null} - {ellipsize(name, 70)} -

- -
-
-
- {!isNil(dupe) ? ( - - - - - - - Dupe - - - ) : null} - - {/* Nicks */} - - - - - - - {users.user.nicks} - + ({ dupe, type, name, id, slots, users, size }, idx) => ( +
+

+ {type.id === "directory" && ( + + )} + {ellipsize(name, 45)} +

+
+
+
+ {!isNil(dupe) && ( + + + Dupe - {/* Flags */} - {users.user.flags.map((flag, idx) => ( - - - - + )} + + + {users.user.nicks} + + {users.user.flags.map((flag, idx) => ( + + + {flag} + + ))} +
+
+
+
- {/* Extension */} - - - - + {/* TYPE */} + + + + {type.str} + + - {/* Slots */} - - - - + {/* SLOTS */} + + + + {slots.total} slots; {slots.free} free + + - -
+ +
diff --git a/src/client/store/index.ts b/src/client/store/index.ts index c52b8a2..d688a8d 100644 --- a/src/client/store/index.ts +++ b/src/client/store/index.ts @@ -1,161 +1,173 @@ import { create } from "zustand"; -import { isNil } from "lodash"; -import io from "socket.io-client"; +import io, { Socket } from "socket.io-client"; import { SOCKET_BASE_URI } from "../constants/endpoints"; -import { produce } from "immer"; +import { isNil } from "lodash"; import { QueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.min.css"; -/* Broadly, this file sets up: - * 1. The zustand-based global client state - * 2. socket.io client - */ -export const useStore = create((set, get) => ({ - // Socket.io state - socketIOInstance: {}, - // ComicVine Scraping status +const queryClient = new QueryClient(); + +// Type for global state +interface StoreState { + socketInstances: Record; + getSocket: (namespace?: string) => Socket; + disconnectSocket: (namespace: string) => void; + + comicvine: { + scrapingStatus: string; + }; + + importJobQueue: { + successfulJobCount: number; + failedJobCount: number; + status: string | undefined; + mostRecentImport: string | null; + + setStatus: (status: string) => void; + setJobCount: (jobType: string, count: number) => void; + setMostRecentImport: (fileName: string) => void; + }; +} + +export const useStore = create((set, get) => ({ + socketInstances: {}, + + getSocket: (namespace = "/") => { + const fullNamespace = namespace === "/" ? "" : namespace; + const existing = get().socketInstances[namespace]; + + if (existing && existing.connected) return existing; + + const sessionId = localStorage.getItem("sessionId"); + const socket = io(`${SOCKET_BASE_URI}${fullNamespace}`, { + transports: ["websocket"], + withCredentials: true, + query: { sessionId }, + }); + + socket.on("connect", () => { + console.log(`✅ Connected to ${namespace}:`, socket.id); + }); + + if (sessionId) { + socket.emit("call", "socket.resumeSession", { sessionId, namespace }); + } else { + socket.on("sessionInitialized", (id) => { + localStorage.setItem("sessionId", id); + }); + } + + socket.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => { + const { completedJobCount, failedJobCount, queueStatus } = data; + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + successfulJobCount: completedJobCount, + failedJobCount, + status: queueStatus, + }, + })); + }); + + socket.on("LS_COVER_EXTRACTED", ({ completedJobCount, importResult }) => { + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + successfulJobCount: completedJobCount, + mostRecentImport: importResult.data.rawFileDetails.name, + }, + })); + }); + + socket.on("LS_COVER_EXTRACTION_FAILED", ({ failedJobCount }) => { + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + failedJobCount, + }, + })); + }); + + socket.on("LS_IMPORT_QUEUE_DRAINED", () => { + localStorage.removeItem("sessionId"); + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + status: "drained", + }, + })); + queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] }); + }); + + socket.on("CV_SCRAPING_STATUS", (data) => { + set((state) => ({ + comicvine: { + ...state.comicvine, + scrapingStatus: data.message, + }, + })); + }); + + socket.on("searchResultsAvailable", (data) => { + toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`); + }); + + set((state) => ({ + socketInstances: { + ...state.socketInstances, + [namespace]: socket, + }, + })); + + return socket; + }, + + disconnectSocket: (namespace: string) => { + const socket = get().socketInstances[namespace]; + if (socket) { + socket.disconnect(); + set((state) => { + const { [namespace]: _, ...rest } = state.socketInstances; + return { socketInstances: rest }; + }); + } + }, + comicvine: { scrapingStatus: "", }, - // Import job queue and associated statuses importJobQueue: { successfulJobCount: 0, failedJobCount: 0, status: undefined, - setStatus: (status: string) => - set( - produce((draftState) => { - draftState.importJobQueue.status = status; - }), - ), - setJobCount: (jobType: string, count: Number) => { - switch (jobType) { - case "successful": - set( - produce((draftState) => { - draftState.importJobQueue.successfulJobCount = count; - }), - ); - break; - - case "failed": - set( - produce((draftState) => { - draftState.importJobQueue.failedJobCount = count; - }), - ); - break; - } - }, mostRecentImport: null, - setMostRecentImport: (fileName: string) => { - set( - produce((state) => { - state.importJobQueue.mostRecentImport = fileName; - }), - ); - }, + + setStatus: (status: string) => + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + status, + }, + })), + + setJobCount: (jobType: string, count: number) => + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + ...(jobType === "successful" + ? { successfulJobCount: count } + : { failedJobCount: count }), + }, + })), + + setMostRecentImport: (fileName: string) => + set((state) => ({ + importJobQueue: { + ...state.importJobQueue, + mostRecentImport: fileName, + }, + })), }, })); - -const { getState, setState } = useStore; -const queryClient = new QueryClient(); - -/** Socket.IO initialization **/ -// 1. Fetch sessionId from localStorage -const sessionId = localStorage.getItem("sessionId"); -// 2. socket.io instantiation -const socketIOInstance = io(SOCKET_BASE_URI, { - transports: ["websocket"], - withCredentials: true, - query: { sessionId }, -}); -// 3. Set the instance in global state -setState({ - socketIOInstance, -}); - -// Socket.io-based session restoration -if (!isNil(sessionId)) { - // 1. Resume the session - socketIOInstance.emit( - "call", - "socket.resumeSession", - { - namespace: "/", - sessionId, - }, - (data) => console.log(data), - ); -} else { - // 1. Inititalize the session and persist the sessionId to localStorage - socketIOInstance.on("sessionInitialized", (sessionId) => { - localStorage.setItem("sessionId", sessionId); - }); -} -// 2. If a job is in progress, restore the job counts and persist those to global state -socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => { - console.log("Active import in progress detected; restoring counts..."); - const { completedJobCount, failedJobCount, queueStatus } = data; - setState((state) => ({ - importJobQueue: { - ...state.importJobQueue, - successfulJobCount: completedJobCount, - failedJobCount, - status: queueStatus, - }, - })); -}); - -// 1a. Act on each comic issue successfully imported/failed, as indicated -// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events -socketIOInstance.on("LS_COVER_EXTRACTED", (data) => { - const { completedJobCount, importResult } = data; - console.log(importResult); - setState((state) => ({ - importJobQueue: { - ...state.importJobQueue, - successfulJobCount: completedJobCount, - mostRecentImport: importResult.data.rawFileDetails.name, - }, - })); -}); -socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => { - const { failedJobCount } = data; - setState((state) => ({ - importJobQueue: { - ...state.importJobQueue, - failedJobCount, - }, - })); -}); - -socketIOInstance.on("searchResultsAvailable", (data) => { - console.log(data); - toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`); -}); - -// 1b. Clear the localStorage sessionId upon receiving the -// LS_IMPORT_QUEUE_DRAINED event -socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => { - localStorage.removeItem("sessionId"); - setState((state) => ({ - importJobQueue: { - ...state.importJobQueue, - status: "drained", - }, - })); - queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] }); -}); - -// ComicVine Scraping status -socketIOInstance.on("CV_SCRAPING_STATUS", (data) => { - setState((state) => ({ - comicvine: { - ...state.comicvine, - scrapingStatus: data.message, - }, - })); -});