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