From 144fd507746af80991d335ebdbc2418844100d5d Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:11:20 +0700 Subject: [PATCH] UI: Add indicator of RFA connection status to overview panel (#2497) --- src/GameOptions/ui/ConnectionBauble.tsx | 40 --------------- src/GameOptions/ui/GameOptionsRoot.tsx | 33 +++++++++---- src/GameOptions/ui/KeyBindingPage.tsx | 4 +- src/GameOptions/ui/RemoteAPIPage.tsx | 6 +-- .../ui/RemoteFileApiConnectionStatus.tsx | 49 +++++++++++++++++++ src/RemoteFileAPI/Remote.ts | 12 +++++ src/RemoteFileAPI/RemoteFileAPI.ts | 14 +++++- src/Sidebar/ui/SidebarRoot.tsx | 4 +- src/ui/Enums.ts | 2 +- src/ui/GameRoot.tsx | 11 ++++- src/ui/React/CharacterOverview.tsx | 2 + src/ui/Router.ts | 4 ++ src/utils/KeyBindingUtils.ts | 4 +- 13 files changed, 122 insertions(+), 63 deletions(-) delete mode 100644 src/GameOptions/ui/ConnectionBauble.tsx create mode 100644 src/GameOptions/ui/RemoteFileApiConnectionStatus.tsx diff --git a/src/GameOptions/ui/ConnectionBauble.tsx b/src/GameOptions/ui/ConnectionBauble.tsx deleted file mode 100644 index a2a62f200..000000000 --- a/src/GameOptions/ui/ConnectionBauble.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Typography } from "@mui/material"; -import React, { useState, useEffect } from "react"; -import WifiIcon from "@mui/icons-material/Wifi"; -import WifiOffIcon from "@mui/icons-material/WifiOff"; - -interface baubleProps { - isConnected: () => boolean; -} - -export const ConnectionBauble = (props: baubleProps): React.ReactElement => { - const [connection, setConnection] = useState(props.isConnected()); - - useEffect(() => { - const timer = setInterval(() => { - setConnection(props.isConnected()); - }, 1000); - return () => clearInterval(timer); - }); - - return ( - <> - - Status:  - - {connection ? ( - <> - Online  - - - ) : ( - <> - Offline  - - - )} - - - - ); -}; diff --git a/src/GameOptions/ui/GameOptionsRoot.tsx b/src/GameOptions/ui/GameOptionsRoot.tsx index f4c5d0903..423f413a8 100644 --- a/src/GameOptions/ui/GameOptionsRoot.tsx +++ b/src/GameOptions/ui/GameOptionsRoot.tsx @@ -1,5 +1,5 @@ import { Box, Container, Typography } from "@mui/material"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { GameOptionsSidebar } from "./GameOptionsSidebar"; import { GameplayPage } from "./GameplayPage"; import { InterfacePage } from "./InterfacePage"; @@ -8,14 +8,7 @@ import { NumericDisplayPage } from "./NumericDisplayOptions"; import { RemoteAPIPage } from "./RemoteAPIPage"; import { SystemPage } from "./SystemPage"; import { KeyBindingPage } from "./KeyBindingPage"; - -interface IProps { - save: () => void; - export: () => void; - forceKill: () => void; - softReset: () => void; - reactivateTutorial: () => void; -} +import { EventEmitter } from "../../utils/EventEmitter"; export type OptionsTabName = | "System" @@ -26,6 +19,15 @@ export type OptionsTabName = | "Remote API" | "Key Binding"; +interface IProps { + tab?: OptionsTabName; + save: () => void; + export: () => void; + forceKill: () => void; + softReset: () => void; + reactivateTutorial: () => void; +} + const tabs: Record = { System: , Interface: , @@ -36,8 +38,19 @@ const tabs: Record = { "Key Binding": , }; +export const GameOptionsPageEvents = new EventEmitter<[OptionsTabName]>(); + export function GameOptionsRoot(props: IProps): React.ReactElement { - const [currentTab, setCurrentTab] = useState("System"); + const [currentTab, setCurrentTab] = useState(props.tab ?? "System"); + + useEffect( + () => + GameOptionsPageEvents.subscribe((tab: OptionsTabName) => { + setCurrentTab(tab); + }), + [], + ); + return ( Options diff --git a/src/GameOptions/ui/KeyBindingPage.tsx b/src/GameOptions/ui/KeyBindingPage.tsx index 5a39f66c6..8affe1014 100644 --- a/src/GameOptions/ui/KeyBindingPage.tsx +++ b/src/GameOptions/ui/KeyBindingPage.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { Settings } from "../../Settings/Settings"; import { getRecordKeys } from "../../Types/Record"; import { Modal } from "../../ui/React/Modal"; -import { SimplePage } from "../../ui/Enums"; +import { ComplexPage } from "../../ui/Enums"; import { KEY } from "../../utils/KeyboardEventKey"; import { areDifferentKeyCombinations, @@ -248,7 +248,7 @@ function SettingUpKeyBindingModal({ export function KeyBindingPage(): React.ReactElement { const [popupOpen, setPopupOpen] = useState(false); - const [keyBindingType, setKeyBindingType] = useState(SimplePage.Options); + const [keyBindingType, setKeyBindingType] = useState(ComplexPage.Options); const [isPrimary, setIsPrimary] = useState(true); const showModal = (keyBindingType: KeyBindingType, isPrimary: boolean) => { diff --git a/src/GameOptions/ui/RemoteAPIPage.tsx b/src/GameOptions/ui/RemoteAPIPage.tsx index 09115e036..453b59578 100644 --- a/src/GameOptions/ui/RemoteAPIPage.tsx +++ b/src/GameOptions/ui/RemoteAPIPage.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { Button, TextField, Tooltip, Typography } from "@mui/material"; import { GameOptionsPage } from "./GameOptionsPage"; import { isValidConnectionHostname, isValidConnectionPort, Settings } from "../../Settings/Settings"; -import { ConnectionBauble } from "./ConnectionBauble"; -import { isRemoteFileApiConnectionLive, newRemoteFileApiConnection } from "../../RemoteFileAPI/RemoteFileAPI"; +import { RemoteFileApiConnectionStatus } from "./RemoteFileApiConnectionStatus"; +import { newRemoteFileApiConnection } from "../../RemoteFileAPI/RemoteFileAPI"; import { OptionSwitch } from "../../ui/React/OptionSwitch"; import { DocumentationLink } from "../../ui/React/DocumentationLink"; @@ -69,7 +69,7 @@ export const RemoteAPIPage = (): React.ReactElement => { Documentation - + diff --git a/src/GameOptions/ui/RemoteFileApiConnectionStatus.tsx b/src/GameOptions/ui/RemoteFileApiConnectionStatus.tsx new file mode 100644 index 000000000..0961e075b --- /dev/null +++ b/src/GameOptions/ui/RemoteFileApiConnectionStatus.tsx @@ -0,0 +1,49 @@ +import React, { useState, useEffect } from "react"; +import { Box, IconButton, Tooltip, Typography } from "@mui/material"; +import { getRemoteFileApiConnectionStatus } from "../../RemoteFileAPI/RemoteFileAPI"; +import OnlinePredictionIcon from "@mui/icons-material/OnlinePrediction"; +import { Settings } from "../../Settings/Settings"; +import { Router } from "../../ui/GameRoot"; +import { Page } from "../../ui/Router"; +import { RemoteFileApiConnectionEvents } from "../../RemoteFileAPI/Remote"; + +export const RemoteFileApiConnectionStatus = ({ showIcon }: { showIcon: boolean }): React.ReactElement => { + const [rfaConnectionStatus, setRfaConnectionStatus] = useState(getRemoteFileApiConnectionStatus()); + + useEffect( + () => + RemoteFileApiConnectionEvents.subscribe((status) => { + setRfaConnectionStatus(status); + }), + [], + ); + + let color; + switch (rfaConnectionStatus) { + case "Online": + color = Settings.theme.success; + break; + case "Offline": + color = Settings.theme.error; + break; + case "Reconnecting": + color = Settings.theme.warning; + break; + } + + return ( + + {showIcon ? ( + Router.toPage(Page.Options, { tab: "Remote API" })}> + + + + + ) : ( + + Status: {rfaConnectionStatus} + + )} + + ); +}; diff --git a/src/RemoteFileAPI/Remote.ts b/src/RemoteFileAPI/Remote.ts index a7ae2bfc9..ea6ec9696 100644 --- a/src/RemoteFileAPI/Remote.ts +++ b/src/RemoteFileAPI/Remote.ts @@ -3,6 +3,8 @@ import { RFARequestHandler } from "./MessageHandlers"; import { SnackbarEvents } from "../ui/React/Snackbar"; import { ToastVariant } from "@enums"; import { Settings } from "../Settings/Settings"; +import { EventEmitter } from "../utils/EventEmitter"; +import type { getRemoteFileApiConnectionStatus } from "./RemoteFileAPI"; function showErrorMessage(address: string, detail: string) { SnackbarEvents.emit(`Error with websocket ${address}, details: ${detail}`, ToastVariant.ERROR, 5000); @@ -10,10 +12,13 @@ function showErrorMessage(address: string, detail: string) { const eventCodeWhenIntentionallyStoppingConnection = 3000; +export const RemoteFileApiConnectionEvents = new EventEmitter<[ReturnType]>(); + export class Remote { connection?: WebSocket; ipaddr: string; port: number; + reconnecting = false; constructor(ip: string, port: number) { this.ipaddr = ip; @@ -22,6 +27,7 @@ export class Remote { public stopConnection(): void { this.connection?.close(eventCodeWhenIntentionallyStoppingConnection); + RemoteFileApiConnectionEvents.emit("Offline"); } public startConnection(autoConnectAttempt = 1): void { @@ -54,6 +60,7 @@ export class Remote { ToastVariant.SUCCESS, 2000, ); + RemoteFileApiConnectionEvents.emit("Online"); }); this.connection.addEventListener("close", (event) => { /** @@ -75,6 +82,7 @@ export class Remote { } if (Settings.RemoteFileApiReconnectionDelay > 0) { + this.reconnecting = true; setTimeout(() => { if (autoConnectAttempt === 1) { SnackbarEvents.emit(`Attempting to auto connect Remote API`, ToastVariant.WARNING, 2000); @@ -85,6 +93,10 @@ export class Remote { this.startConnection(attempts); }, Settings.RemoteFileApiReconnectionDelay * 1000); + RemoteFileApiConnectionEvents.emit("Reconnecting"); + } else { + this.reconnecting = false; + RemoteFileApiConnectionEvents.emit("Offline"); } }); } diff --git a/src/RemoteFileAPI/RemoteFileAPI.ts b/src/RemoteFileAPI/RemoteFileAPI.ts index d15dc6d53..e0a8dea08 100644 --- a/src/RemoteFileAPI/RemoteFileAPI.ts +++ b/src/RemoteFileAPI/RemoteFileAPI.ts @@ -1,7 +1,7 @@ import { Settings } from "../Settings/Settings"; import { Remote } from "./Remote"; -let server: Remote; +let server: Remote | undefined; export function newRemoteFileApiConnection(): void { if (server) server.stopConnection(); @@ -14,5 +14,15 @@ export function newRemoteFileApiConnection(): void { } export function isRemoteFileApiConnectionLive(): boolean { - return server && server.connection != undefined && server.connection.readyState == 1; + return server !== undefined && server.connection !== undefined && server.connection.readyState === 1; +} + +export function getRemoteFileApiConnectionStatus(): "Online" | "Offline" | "Reconnecting" { + if (isRemoteFileApiConnectionLive()) { + return "Online"; + } + if (server?.reconnecting) { + return "Reconnecting"; + } + return "Offline"; } diff --git a/src/Sidebar/ui/SidebarRoot.tsx b/src/Sidebar/ui/SidebarRoot.tsx index e03086101..b335108ea 100644 --- a/src/Sidebar/ui/SidebarRoot.tsx +++ b/src/Sidebar/ui/SidebarRoot.tsx @@ -181,7 +181,7 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement { const clickPage = useCallback( (page: Page) => { - if (page == Page.ScriptEditor || page == Page.Documentation) { + if (page == Page.ScriptEditor || page == Page.Documentation || page == Page.Options) { Router.toPage(page, {}); } else if (isSimplePage(page)) { Router.toPage(page); @@ -212,7 +212,7 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement { case SimplePage.Milestones: case ComplexPage.Documentation: case SimplePage.Achievements: - case SimplePage.Options: + case ComplexPage.Options: return true; case SimplePage.StaneksGift: return canStaneksGift; diff --git a/src/ui/Enums.ts b/src/ui/Enums.ts index caff66361..6c63eda72 100644 --- a/src/ui/Enums.ts +++ b/src/ui/Enums.ts @@ -27,7 +27,6 @@ export enum SimplePage { Hacknet = "Hacknet", Infiltration = "Infiltration", Milestones = "Milestones", - Options = "Options", Grafting = "Grafting", Sleeves = "Sleeves", Stats = "Stats", @@ -52,6 +51,7 @@ export enum ComplexPage { Location = "Location", ImportSave = "Import Save", Documentation = "Documentation", + Options = "Options", // LoadingScreen is a special state that should never be returned to after the initial game load. To enforce this, it // is constructed as a ComplexPage with no PageContext, and thus toPage() cannot be used (since no overload will fit // it). diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index a3efd66ec..db296fcdd 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -29,7 +29,7 @@ import { CorporationRoot } from "../Corporation/ui/CorporationRoot"; import { InfiltrationRoot } from "../Infiltration/ui/InfiltrationRoot"; import { GraftingRoot } from "../PersonObjects/Grafting/ui/GraftingRoot"; import { WorkInProgressRoot } from "./WorkInProgressRoot"; -import { GameOptionsRoot } from "../GameOptions/ui/GameOptionsRoot"; +import { GameOptionsPageEvents, GameOptionsRoot } from "../GameOptions/ui/GameOptionsRoot"; import { SleeveRoot } from "../PersonObjects/Sleeve/ui/SleeveRoot"; import { HacknetRoot } from "../Hacknet/ui/HacknetRoot"; import { GenericLocation } from "../Locations/ui/GenericLocation"; @@ -257,6 +257,14 @@ export function GameRoot(): React.ReactElement { prestigeWorkerScripts(); calculateAchievements(); break; + case Page.Options: + // If the current page is "Options" and something calls Router.toPage("Options", { tab: "Foo" }) to switch the + // tab, we need to emit an event to tell GameOptionsRoot to set its currentTab state. Changing the tab in the + // properties of GameOptionsRoot does not set the state. + if (Router.page() === Page.Options && context && "tab" in context && context.tab != null) { + GameOptionsPageEvents.emit(context.tab); + } + break; } setNextPage({ page, ...context } as PageWithContext); }, @@ -425,6 +433,7 @@ export function GameRoot(): React.ReactElement { case Page.Options: { mainPage = ( { saveObject.saveGame().catch((error) => exceptionAlert(error)); }} diff --git a/src/ui/React/CharacterOverview.tsx b/src/ui/React/CharacterOverview.tsx index 9018f57d8..a432182f6 100644 --- a/src/ui/React/CharacterOverview.tsx +++ b/src/ui/React/CharacterOverview.tsx @@ -29,6 +29,7 @@ import { isCompanyWork } from "../../Work/CompanyWork"; import { isCrimeWork } from "../../Work/CrimeWork"; import { EventEmitter } from "../../utils/EventEmitter"; import { useRerender } from "./hooks"; +import { RemoteFileApiConnectionStatus } from "../../GameOptions/ui/RemoteFileApiConnectionStatus"; export const OverviewEventEmitter = new EventEmitter(); @@ -183,6 +184,7 @@ export function CharacterOverview({ parentOpen, save, killScripts }: OverviewPro + setKillOpen(true)}> diff --git a/src/ui/Router.ts b/src/ui/Router.ts index dda1ab667..d5cc5b3c5 100644 --- a/src/ui/Router.ts +++ b/src/ui/Router.ts @@ -3,6 +3,7 @@ import type { TextFilePath } from "../Paths/TextFilePath"; import type { Faction } from "../Faction/Faction"; import type { Location } from "../Locations/Location"; import type { SaveData } from "../types"; +import type { OptionsTabName } from "../GameOptions/ui/GameOptionsRoot"; import { ComplexPage, SimplePage } from "./Enums"; // Using the same name as both type and object to mimic enum-like behavior. @@ -24,6 +25,8 @@ export type PageContext = T extends ComplexPage.BitVerse ? { saveData: SaveData; automatic?: boolean } : T extends ComplexPage.Documentation ? { docPage?: string } + : T extends ComplexPage.Options + ? { tab?: OptionsTabName } : never; export type PageWithContext = @@ -34,6 +37,7 @@ export type PageWithContext = | ({ page: ComplexPage.Location } & PageContext) | ({ page: ComplexPage.ImportSave } & PageContext) | ({ page: ComplexPage.Documentation } & PageContext) + | ({ page: ComplexPage.Options } & PageContext) | { page: ComplexPage.LoadingScreen } | { page: SimplePage }; diff --git a/src/utils/KeyBindingUtils.ts b/src/utils/KeyBindingUtils.ts index 54ef26d8f..9b8cf0cf4 100644 --- a/src/utils/KeyBindingUtils.ts +++ b/src/utils/KeyBindingUtils.ts @@ -36,7 +36,7 @@ export const GoToPageKeyBindingTypes = [ SimplePage.Milestones, ComplexPage.Documentation, SimplePage.Achievements, - SimplePage.Options, + ComplexPage.Options, ...SpoilerKeyBindingTypes, ] as const; @@ -213,7 +213,7 @@ export const DefaultKeyBindings: Record