UI: Add indicator of RFA connection status to overview panel (#2497)

This commit is contained in:
catloversg
2026-02-25 03:11:20 +07:00
committed by GitHub
parent c85d9cbe8c
commit 144fd50774
13 changed files with 122 additions and 63 deletions

View File

@@ -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 (
<>
<Typography>
Status:&nbsp;
<Typography component="span" color={connection ? "primary" : "error"}>
{connection ? (
<>
Online&nbsp;
<WifiIcon />
</>
) : (
<>
Offline&nbsp;
<WifiOffIcon />
</>
)}
</Typography>
</Typography>
</>
);
};

View File

@@ -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<OptionsTabName, React.ReactNode> = {
System: <SystemPage />,
Interface: <InterfacePage />,
@@ -36,8 +38,19 @@ const tabs: Record<OptionsTabName, React.ReactNode> = {
"Key Binding": <KeyBindingPage />,
};
export const GameOptionsPageEvents = new EventEmitter<[OptionsTabName]>();
export function GameOptionsRoot(props: IProps): React.ReactElement {
const [currentTab, setCurrentTab] = useState<OptionsTabName>("System");
const [currentTab, setCurrentTab] = useState<OptionsTabName>(props.tab ?? "System");
useEffect(
() =>
GameOptionsPageEvents.subscribe((tab: OptionsTabName) => {
setCurrentTab(tab);
}),
[],
);
return (
<Container disableGutters maxWidth="lg" sx={{ mx: 0 }}>
<Typography variant="h4">Options</Typography>

View File

@@ -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<KeyBindingType>(SimplePage.Options);
const [keyBindingType, setKeyBindingType] = useState<KeyBindingType>(ComplexPage.Options);
const [isPrimary, setIsPrimary] = useState(true);
const showModal = (keyBindingType: KeyBindingType, isPrimary: boolean) => {

View File

@@ -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 => {
<Typography>
<DocumentationLink page="programming/remote_api.md">Documentation</DocumentationLink>
</Typography>
<ConnectionBauble isConnected={isRemoteFileApiConnectionLive} />
<RemoteFileApiConnectionStatus showIcon={false} />
<Tooltip
title={
<Typography>

View File

@@ -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 (
<Box style={{ display: "flex", flex: 1, justifyContent: "flex-start", alignItems: "center" }}>
{showIcon ? (
<IconButton aria-label="Remote API status" onClick={() => Router.toPage(Page.Options, { tab: "Remote API" })}>
<Tooltip title={`Remote API: ${rfaConnectionStatus}`}>
<OnlinePredictionIcon style={{ fontSize: "30px", color }} />
</Tooltip>
</IconButton>
) : (
<Typography>
Status: <span style={{ color }}>{rfaConnectionStatus}</span>
</Typography>
)}
</Box>
);
};

View File

@@ -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<typeof getRemoteFileApiConnectionStatus>]>();
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");
}
});
}

View File

@@ -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";
}

View File

@@ -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;

View File

@@ -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).

View File

@@ -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 = (
<GameOptionsRoot
tab={pageWithContext.tab}
save={() => {
saveObject.saveGame().catch((error) => exceptionAlert(error));
}}

View File

@@ -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
</Tooltip>
</IconButton>
</Box>
<RemoteFileApiConnectionStatus showIcon={true} />
<Box sx={{ display: "flex", flex: 1, justifyContent: "flex-end", alignItems: "center" }}>
<IconButton aria-label="kill all scripts" onClick={() => setKillOpen(true)}>
<Tooltip title="Kill all running scripts">

View File

@@ -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 Page> = 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<ComplexPage.Location>)
| ({ page: ComplexPage.ImportSave } & PageContext<ComplexPage.ImportSave>)
| ({ page: ComplexPage.Documentation } & PageContext<ComplexPage.Documentation>)
| ({ page: ComplexPage.Options } & PageContext<ComplexPage.Options>)
| { page: ComplexPage.LoadingScreen }
| { page: SimplePage };

View File

@@ -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<KeyBindingType, [KeyCombination | null,
null,
],
[SimplePage.Achievements]: [null, null],
[SimplePage.Options]: [
[ComplexPage.Options]: [
{
control: false,
alt: true,