diff --git a/CHANGELOG.md b/CHANGELOG.md index 149020ba..92535742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added - Added better error message when no files to backup ([support#681]). +- Added multi-step firmware flashing dialog. ### Fixed - Fixed deleting files that are not open in the editor. diff --git a/src/alerts/sagas.ts b/src/alerts/sagas.ts index f6421dc0..f12a29c9 100644 --- a/src/alerts/sagas.ts +++ b/src/alerts/sagas.ts @@ -34,6 +34,8 @@ function* handleShowAlert(action: ReturnType): Generator try { const alertAction = yield* take(chan); + // the dismiss actions will have called this already, but other actions don't + toaster.dismiss(key); yield* put(alertsDidShowAlert(action.domain, action.specific, alertAction)); } finally { diff --git a/src/app/constants.ts b/src/app/constants.ts index d4e99b8e..f145da7d 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // Definitions for compile-time UI settings. @@ -32,6 +32,9 @@ export const pybricksBugReportsUrl = 'https://github.com/pybricks/support/issues /** URL for Pybricks community chat on Gitter */ export const pybricksGitterUrl = 'https://gitter.im/pybricks/community'; +export const pybricksBluetoothTroubleshootingUrl = + 'https://github.com/pybricks/support/discussions/270'; + /** Pybricks copyright statement. */ export const pybricksCopyright = 'Copyright (c) 2020-2022 The Pybricks Authors'; diff --git a/src/ble/alerts/NoHub.tsx b/src/ble/alerts/NoHub.tsx new file mode 100644 index 00000000..ce80b181 --- /dev/null +++ b/src/ble/alerts/NoHub.tsx @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import './noHub.scss'; +import { AnchorButton, Button, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { appName, pybricksBluetoothTroubleshootingUrl } from '../../app/constants'; +import { CreateToast } from '../../i18nToaster'; +import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; +import { I18nId, useI18n } from './i18n'; + +type NoHubProps = { + onFlashFirmware: () => void; +}; + +const NoHub: React.VoidFunctionComponent = ({ onFlashFirmware }) => { + const i18n = useI18n(); + + return ( + <> +

{i18n.translate(I18nId.NoHubMessage)}

+

+ {i18n.translate(I18nId.NoHubSuggestion1, { + appName, + buttonName: ( + + {i18n.translate(I18nId.NoHubFlashFirmwareButton)} + + ), + })} +

+

{i18n.translate(I18nId.NoHubSuggestion2)}

+
+ + + {i18n.translate(I18nId.NoHubTroubleshootButton)} + + +
+ + ); +}; + +export const noHub: CreateToast = (onAction) => { + return { + message: onAction('flashFirmware')} />, + icon: 'info-sign', + intent: Intent.PRIMARY, + timeout: 15000, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/i18n.ts b/src/ble/alerts/i18n.ts index c524cbde..81045edd 100644 --- a/src/ble/alerts/i18n.ts +++ b/src/ble/alerts/i18n.ts @@ -19,4 +19,9 @@ export enum I18nId { MissingServiceMessage = 'missingService.message', MissingServiceSuggestion1 = 'missingService.suggestion1', MissingServiceSuggestion2 = 'missingService.suggestion2', + NoHubMessage = 'noHub.message', + NoHubSuggestion1 = 'noHub.suggestion1', + NoHubSuggestion2 = 'noHub.suggestion2', + NoHubFlashFirmwareButton = 'noHub.flashFirmwareButton', + NoHubTroubleshootButton = 'noHub.troubleshootButton', } diff --git a/src/ble/alerts/index.ts b/src/ble/alerts/index.ts index 65488a6a..2e5ec4dd 100644 --- a/src/ble/alerts/index.ts +++ b/src/ble/alerts/index.ts @@ -4,7 +4,8 @@ import { bluetoothNotAvailable } from './BluetoothNotAvailable'; import { missingService } from './MissingService'; import { noGatt } from './NoGatt'; +import { noHub } from './NoHub'; import { noWebBluetooth } from './NoWebBluetooth'; // gathers all of the alert creation functions for passing up to the top level -export default { bluetoothNotAvailable, missingService, noGatt, noWebBluetooth }; +export default { bluetoothNotAvailable, missingService, noGatt, noHub, noWebBluetooth }; diff --git a/src/ble/alerts/noHub.scss b/src/ble/alerts/noHub.scss new file mode 100644 index 00000000..c011e9a3 --- /dev/null +++ b/src/ble/alerts/noHub.scss @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +.pb-ble-alerts-noHub { + &-buttons { + display: flex; + gap: 10px; + } +} diff --git a/src/ble/alerts/translations/en.json b/src/ble/alerts/translations/en.json index 3e90df14..94c9f34d 100644 --- a/src/ble/alerts/translations/en.json +++ b/src/ble/alerts/translations/en.json @@ -16,5 +16,12 @@ "message": "Connected to hub but failed to get {serviceName} service.", "suggestion1": "Ensure that you are using the most recent firmware.", "suggestion2": "If the problem persists, try removing the \"{hubName}\" device in your OS Bluetooth settings, then try connecting again." + }, + "noHub": { + "message": "Could not find your hub?", + "suggestion1": "{appName} requires custom firmware to be flashed to your hub. If you have not done this already, click the {buttonName} button below to do so now.", + "suggestion2": "If you have flashed the Pybricks firmware to the hub already and you are still having problems connecting, please visit the troubleshooting guide.", + "flashFirmwareButton": "Flash Firmware", + "troubleshootButton": "Troubleshooting Tips" } } diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index 096009c7..f58f2086 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -4,7 +4,7 @@ import { HubType } from '@pybricks/firmware'; import { MockProxy, mock } from 'jest-mock-extended'; import { AsyncSaga } from '../../test'; -import { alertsShowAlert } from '../alerts/actions'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; import { bleDIServiceDidReceiveFirmwareRevision, bleDIServiceDidReceivePnPId, @@ -26,6 +26,7 @@ import { pybricksControlCharacteristicUUID, pybricksServiceUUID, } from '../ble-pybricks-service/protocol'; +import { firmwareInstallPybricks } from '../firmware/actions'; import { bleConnectPybricks, bleDidConnectPybricks, @@ -274,7 +275,11 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); + await expect(saga.take()).resolves.toEqual(alertsShowAlert('ble', 'noHub')); await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + saga.put(alertsDidShowAlert('ble', 'noHub', 'flashFirmware')); + await expect(saga.take()).resolves.toEqual(firmwareInstallPybricks()); }); it('should fail on other exception in requestDevice', async () => { diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 6f8a39d7..87d43e1f 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -17,7 +17,7 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { alertsShowAlert } from '../alerts/actions'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; import { bleDIServiceDidReceiveFirmwareRevision, bleDIServiceDidReceivePnPId, @@ -51,6 +51,7 @@ import { pybricksControlCharacteristicUUID, pybricksServiceUUID, } from '../ble-pybricks-service/protocol'; +import { firmwareInstallPybricks } from '../firmware/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; import { @@ -141,7 +142,21 @@ function* handleBleConnectPybricks(): Generator { ); if (!device) { + yield* put(alertsShowAlert('ble', 'noHub')); yield* put(bleDidFailToConnectPybricks()); + + const { action } = yield* take< + ReturnType> + >( + alertsDidShowAlert.when( + (a) => a.domain === 'ble' && a.specific === 'noHub', + ), + ); + + if (action === 'flashFirmware') { + yield* put(firmwareInstallPybricks()); + } + return; } diff --git a/src/components/hubPicker/HubPicker.tsx b/src/components/hubPicker/HubPicker.tsx new file mode 100644 index 00000000..13889395 --- /dev/null +++ b/src/components/hubPicker/HubPicker.tsx @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Radio, RadioGroup } from '@blueprintjs/core'; +import React from 'react'; +import { Hub } from '.'; + +type HubPickerProps = { + hubType: Hub; + onChange: (hubType: Hub) => void; +}; + +export const HubPicker: React.VoidFunctionComponent = ({ + hubType, + onChange, +}) => { + return ( + onChange(e.currentTarget.value as Hub)} + > + BOOST Move Hub + City Hub + Technic Hub + SPIKE Prime Hub + SPIKE Essential Hub + MINDSTORMS Robot Inventor Hub + + ); +}; diff --git a/src/components/hubPicker/index.ts b/src/components/hubPicker/index.ts new file mode 100644 index 00000000..95f65b2b --- /dev/null +++ b/src/components/hubPicker/index.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +/** Supported hub types. */ +export enum Hub { + /** BOOST Move hub */ + Move = 'movehub', + /** City hub */ + City = 'cityhub', + /** Technic hub */ + Technic = 'technichub', + /** MINDSTORMS Robot Inventor hub */ + Inventor = 'inventorhub', + /** SPIKE Prime hub */ + Prime = 'primehub', + /** SPIKE Essential hub */ + Essential = 'essentialhub', +} + +/** + * Tests if hub has a USB port. + */ +export function hubHasUSB(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** + * Tests if hub has a Bluetooth button. + */ +export function hubHasBluetoothButton(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** + * Tests if hub has external flash memory. + */ +export function hubHasExternalFlash(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return true; + default: + return false; + } +} diff --git a/src/explorer/newFileWizard/NewFileWizard.test.tsx b/src/explorer/newFileWizard/NewFileWizard.test.tsx index c2755314..5b1ba4ac 100644 --- a/src/explorer/newFileWizard/NewFileWizard.test.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.test.tsx @@ -5,8 +5,9 @@ import { fireEvent, waitFor } from '@testing-library/dom'; import { cleanup } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../../test'; +import { Hub } from '../../components/hubPicker'; import NewFileWizard from './NewFileWizard'; -import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; +import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; afterEach(() => { cleanup(); diff --git a/src/explorer/newFileWizard/NewFileWizard.tsx b/src/explorer/newFileWizard/NewFileWizard.tsx index 37947720..54981736 100644 --- a/src/explorer/newFileWizard/NewFileWizard.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.tsx @@ -1,17 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { - Button, - Classes, - Dialog, - FormGroup, - Radio, - RadioGroup, -} from '@blueprintjs/core'; +import { Button, Classes, Dialog, FormGroup } from '@blueprintjs/core'; import React, { useCallback, useRef, useState } from 'react'; import { useId } from 'react-aria'; import { useDispatch } from 'react-redux'; +import { Hub } from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; import { useFileStorageMetadata } from '../../fileStorage/hooks'; import { FileNameValidationResult, @@ -20,7 +15,7 @@ import { } from '../../pybricksMicropython/lib'; import { useSelector } from '../../reducers'; import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup'; -import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; +import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; import { I18nId, useI18n } from './i18n'; // This should be set to the most commonly used hub. @@ -75,19 +70,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => { onChange={setFileName} /> - setHubType(e.currentTarget.value as Hub)} - > - BOOST Move Hub - City Hub - Technic Hub - SPIKE Prime - SPIKE Essential - - MINDSTORMS Robot Inventor - - +
diff --git a/src/explorer/newFileWizard/actions.ts b/src/explorer/newFileWizard/actions.ts index 2ce71e67..b8d9ab20 100644 --- a/src/explorer/newFileWizard/actions.ts +++ b/src/explorer/newFileWizard/actions.ts @@ -2,28 +2,13 @@ // Copyright (c) 2022 The Pybricks Authors import { createAction } from '../../actions'; +import { Hub } from '../../components/hubPicker'; import { pythonFileExtension } from '../../pybricksMicropython/lib'; /** Supported file extensions. */ type SupportedFileExtension = typeof pythonFileExtension; -/** Supported hub types. */ -export enum Hub { - /** BOOST Move hub */ - Move = 'movehub', - /** City hub */ - City = 'cityhub', - /** Technic hub */ - Technic = 'technichub', - /** MINDSTORMS Robot Inventor hub */ - Inventor = 'inventorhub', - /** SPIKE Prime hub */ - Prime = 'primehub', - /** SPIKE Essential hub */ - Essential = 'essentialhub', -} - /** * Requests to show the new file wizard dialog. */ diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index 2cfdf7f7..528618d7 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -6,6 +6,7 @@ import { FileWithHandle } from 'browser-fs-access'; import { mock } from 'jest-mock-extended'; import { AsyncSaga, uuid } from '../../test'; import { alertsShowAlert } from '../alerts/actions'; +import { Hub } from '../components/hubPicker'; import { editorActivateFile, editorCloseFile, @@ -72,7 +73,6 @@ import { } from './duplicateFileDialog/actions'; import { ExplorerError, ExplorerErrorName } from './error'; import { - Hub, newFileWizardDidAccept, newFileWizardDidCancel, newFileWizardShow, diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index e441c44c..793db68c 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -345,3 +345,45 @@ function didFailToFinishCreator( * @param total The total number of bytes to be flashed. */ export const didFailToFinish = createAction(didFailToFinishCreator); + +/** + * Action that triggers the install Pybricks firmware saga. + */ +export const firmwareInstallPybricks = createAction(() => ({ + type: 'firmware.action.installPybricks', +})); + +/** + * Action that indicates {@link firmwareInstallPybricks} succeeded. + */ +export const firmwareDidInstallPybricks = createAction(() => ({ + type: 'firmware.action.didInstallPybricks', +})); + +/** + * Action that indicates {@link firmwareInstallPybricks} failed. + */ +export const firmwareDidFailToInstallPybricks = createAction(() => ({ + type: 'firmware.action.didFailToInstallPybricks', +})); + +/** + * Action that triggers the restore LEGO firmware saga. + */ +export const firmwareRestoreLego = createAction(() => ({ + type: 'firmware.action.restoreLego', +})); + +/** + * Action that indicates {@link firmwareRestoreLego} succeeded. + */ +export const firmwareDidRestoreLego = createAction(() => ({ + type: 'firmware.action.didRestoreLego', +})); + +/** + * Action that indicates {@link firmwareRestoreLego} failed. + */ +export const firmwareDidFailToRestoreLego = createAction(() => ({ + type: 'firmware.action.didFailToRestoreLego', +})); diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx new file mode 100644 index 00000000..8b581277 --- /dev/null +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import './installPybricksDialog.scss'; +import { + Button, + Checkbox, + Classes, + ControlGroup, + DialogStep, + FormGroup, + IRef, + Icon, + InputGroup, + Intent, + MultistepDialog, + NonIdealState, + Spinner, + Switch, +} from '@blueprintjs/core'; +import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2'; +import classNames from 'classnames'; +import React, { useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { appName } from '../../app/constants'; +import HelpButton from '../../components/HelpButton'; +import { + Hub, + hubHasBluetoothButton, + hubHasExternalFlash, + hubHasUSB, +} from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; +import { useSelector } from '../../reducers'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, +} from './actions'; +import { useFirmware } from './hooks'; +import { I18nId, useI18n } from './i18n'; +import { validateHubName } from '.'; + +const dialogBody = classNames( + Classes.DIALOG_BODY, + 'pb-firmware-installPybricksDialog-body', +); + +type SelectHubPanelProps = { + hubType: Hub; + onChange: (hubType: Hub) => void; +}; + +const SelectHubPanel: React.VoidFunctionComponent = ({ + hubType, + onChange, +}) => { + const i18n = useI18n(); + + return ( +
+

{i18n.translate(I18nId.SelectHubPanelMessage)}

+ + +

+ {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsTitle, + )} +

+
    +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsRcx, + )} +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsNxt, + )} +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsEv3, + )} +
  • +
+

+ {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpTitle, + )} +

+
    +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpWedo2, + )} + * +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain, + )} + * +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpMario, + )} +
  • +
+ + + *{' '} + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpFootnote, + )} + +
+ } + renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( + + )} + /> +
+ ); +}; + +type AcceptLicensePanelProps = { + hubType: Hub; + licenseAccepted: boolean; + onLicenseAcceptedChanged: (accepted: boolean) => void; +}; + +const AcceptLicensePanel: React.VoidFunctionComponent = ({ + hubType, + licenseAccepted, + onLicenseAcceptedChanged, +}) => { + const { data, error } = useFirmware(hubType); + const i18n = useI18n(); + + return ( +
+
+
+ {data &&
{data.licenseText}
} + {!data && ( + + {(error && + i18n.translate( + I18nId.LicensePanelLicenseTextError, + )) || } + + )} +
+ onLicenseAcceptedChanged(e.currentTarget.checked)} + disabled={!data} + /> +
+
+ ); +}; + +type SelectOptionsPanelProps = { + hubType: Hub; + hubName: string; + includeProgram: boolean; + onChangeHubName(hubName: string): void; + onChangeIncludeProgram(includeProgram: boolean): void; +}; + +const ConfigureOptionsPanel: React.VoidFunctionComponent = ({ + hubType, + hubName, + includeProgram, + onChangeHubName, + onChangeIncludeProgram, +}) => { + const i18n = useI18n(); + const isHubNameValid = validateHubName(hubName); + + return ( +
+ + + onChangeHubName(e.currentTarget.value)} + onMouseOver={(e) => e.preventDefault()} + onMouseDown={(e) => e.stopPropagation()} + intent={isHubNameValid ? Intent.NONE : Intent.DANGER} + placeholder="Pybricks Hub" + rightElement={ + isHubNameValid ? undefined : ( + + ) + } + /> + + + + + {(hubHasExternalFlash(hubType) && ( +

+ {i18n.translate( + I18nId.OptionsPanelCustomMainNotApplicableMessage, + )} +

+ )) || ( + + + onChangeIncludeProgram( + (e.target as HTMLInputElement).checked, + ) + } + /> + + + )} +
+
+ ); +}; + +type BootloaderModePanelProps = { + hubType: Hub; +}; + +const BootloaderModePanel: React.VoidFunctionComponent = ({ + hubType, +}) => { + const i18n = useI18n(); + + const { button, light, lightPattern } = useMemo(() => { + return { + button: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelButtonBluetooth + : I18nId.BootloaderPanelButtonPower, + ), + light: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelLightBluetooth + : I18nId.BootloaderPanelLightStatus, + ), + lightPattern: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelLightPatternBluetooth + : I18nId.BootloaderPanelLightPatternStatus, + ), + }; + }, [i18n, hubType]); + + return ( +
+

{i18n.translate(I18nId.BootloaderPanelInstruction1)}

+
    + {hubHasUSB(hubType) && ( +
  1. {i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}
  2. + )} + +
  3. {i18n.translate(I18nId.BootloaderPanelStepPowerOff)}
  4. + + {/* City hub has power issues and requires disconnecting motors/sensors */} + {hubType === Hub.City && ( +
  5. {i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}
  6. + )} + +
  7. + {i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })} +
  8. + + {hubHasUSB(hubType) && ( +
  9. {i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}
  10. + )} + +
  11. + {i18n.translate(I18nId.BootloaderPanelStepWaitForLight, { + button, + light, + lightPattern, + })} +
  12. + +
  13. + {i18n.translate( + /* hubs with USB will keep the power on, but other hubs won't */ + hubHasUSB(hubType) + ? I18nId.BootloaderPanelStepReleaseButton + : I18nId.BootloaderPanelStepKeepHolding, + { + button, + }, + )} +
  14. +
+

+ {i18n.translate(I18nId.BootloaderPanelInstruction2, { + flashFirmware: ( + + {i18n.translate(I18nId.FlashFirmwareButtonLabel)} + + ), + })} +

+
+ ); +}; + +const defaultHubType = Hub.Technic; + +export const InstallPybricksDialog: React.VoidFunctionComponent = () => { + const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog); + const dispatch = useDispatch(); + const [hubType, setHubType] = useState(defaultHubType); + const [hubName, setHubName] = useState(''); + const [includeProgram, setIncludeProgram] = useState(false); + const [licenseAccepted, setLicenseAccepted] = useState(false); + const { data } = useFirmware(hubType); + const i18n = useI18n(); + + return ( + dispatch(firmwareInstallPybricksDialogCancel())} + finalButtonProps={{ + text: i18n.translate(I18nId.FlashFirmwareButtonLabel), + onClick: () => + dispatch( + firmwareInstallPybricksDialogAccept( + data?.firmwareZip ?? new ArrayBuffer(0), + undefined, + hubName, + ), + ), + }} + > + } + nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }} + /> + + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + nextButtonProps={{ + disabled: !licenseAccepted, + text: i18n.translate(I18nId.NextButtonLabel), + }} + /> + + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }} + /> + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + /> + + ); +}; diff --git a/src/firmware/installPybricksDialog/actions.ts b/src/firmware/installPybricksDialog/actions.ts new file mode 100644 index 00000000..7b9e6722 --- /dev/null +++ b/src/firmware/installPybricksDialog/actions.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** Actions that request the install Pybricks firmware dialog to be shown. */ +export const firmwareInstallPybricksDialogShow = createAction(() => ({ + type: 'firmware.installPybricksDialog.action.show', +})); + +/** Actions that indicates the user accepted the install Pybricks firmware dialog. */ +export const firmwareInstallPybricksDialogAccept = createAction( + (firmwareZip: ArrayBuffer, customProgram: string | undefined, hubName: string) => ({ + type: 'firmware.installPybricksDialog.action.accept', + firmwareZip, + customProgram, + hubName, + }), +); + +/** Actions that indicates the user canceled the install Pybricks firmware dialog. */ +export const firmwareInstallPybricksDialogCancel = createAction(() => ({ + type: 'firmware.installPybricksDialog.action.cancel', +})); diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts new file mode 100644 index 00000000..ad0581ed --- /dev/null +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors +// based on https://usehooks-ts.com/react-hook/use-fetch + +import { FirmwareReader } from '@pybricks/firmware'; +import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; +import moveHubZip from '@pybricks/firmware/build/movehub.zip'; +import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { useEffect, useReducer, useRef } from 'react'; +import { Hub } from '../../components/hubPicker'; + +type FirmwareData = { + firmwareZip: ArrayBuffer; + licenseText: string; +}; + +interface State { + /** The firmware.zip data or undefined if `fetch()` is not complete or on error. */ + data?: FirmwareData; + /** Undefined `fetch()` is not complete yet or was successful, otherwise the error. */ + error?: Error; +} + +type Cache = { [url: string]: FirmwareData }; + +// discriminated union type +type Action = + | { type: 'loading' } + | { type: 'fetched'; payload: FirmwareData } + | { type: 'error'; payload: Error }; + +const firmwareZipMap = new Map([ + [Hub.City, cityHubZip], + [Hub.Technic, technicHubZip], + [Hub.Move, moveHubZip], +]); + +/** + * Gets Pybricks firmware .zip file for the specified hub type. + * @param hubType The hub type. + * @returns The current state. + */ +export function useFirmware(hubType: Hub): State { + const url = firmwareZipMap.get(hubType); + const cache = useRef({}); + + // Used to prevent state update if the component is unmounted + const cancelRequest = useRef(false); + + const initialState: State = { + error: undefined, + data: undefined, + }; + + // Keep state logic separated + const fetchReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'loading': + return { ...initialState }; + case 'fetched': + return { ...initialState, data: action.payload }; + case 'error': + return { ...initialState, error: action.payload }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(fetchReducer, initialState); + + useEffect(() => { + // Do nothing if the url is not given + if (!url) { + return; + } + + cancelRequest.current = false; + + const fetchData = async () => { + dispatch({ type: 'loading' }); + + // If a cache exists for this url, return it + if (cache.current[url]) { + dispatch({ type: 'fetched', payload: cache.current[url] }); + return; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const firmwareZip = await response.arrayBuffer(); + const reader = await FirmwareReader.load(firmwareZip); + const licenseText = await reader.readReadMeOss(); + const data = { firmwareZip, licenseText }; + + cache.current[url] = data; + if (cancelRequest.current) { + return; + } + + dispatch({ type: 'fetched', payload: data }); + } catch (error) { + if (process.env.NODE_ENV !== 'test') { + console.error(error); + } + + if (cancelRequest.current) { + return; + } + + dispatch({ type: 'error', payload: error as Error }); + } + }; + + void fetchData(); + + // Use the cleanup function for avoiding a possible + // state update after the component was unmounted + return () => { + cancelRequest.current = true; + }; + }, [url]); + + return state; +} diff --git a/src/toolbar/buttons/flash/i18n.test.ts b/src/firmware/installPybricksDialog/i18n.en.test.ts similarity index 75% rename from src/toolbar/buttons/flash/i18n.test.ts rename to src/firmware/installPybricksDialog/i18n.en.test.ts index b8f901e0..3b098b20 100644 --- a/src/toolbar/buttons/flash/i18n.test.ts +++ b/src/firmware/installPybricksDialog/i18n.en.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors -import { lookup } from '../../../../test'; +import { lookup } from '../../../test'; import { I18nId } from './i18n'; import en from './translations/en.json'; diff --git a/src/firmware/installPybricksDialog/i18n.ts b/src/firmware/installPybricksDialog/i18n.ts new file mode 100644 index 00000000..ffd9e779 --- /dev/null +++ b/src/firmware/installPybricksDialog/i18n.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2022 The Pybricks Authors +// +// Settings translation keys. + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + Title = 'title', + SelectHubPanelTitle = 'selectHubPanel.title', + SelectHubPanelMessage = 'selectHubPanel.message', + SelectHubPanelNotOnListButtonLabel = 'selectHubPanel.notOnListButton.label', + SelectHubPanelNotOnListButtonInfoMindstormsTitle = 'selectHubPanel.notOnListButton.info.mindstorms.title', + SelectHubPanelNotOnListButtonInfoMindstormsRcx = 'selectHubPanel.notOnListButton.info.mindstorms.rcx', + SelectHubPanelNotOnListButtonInfoMindstormsNxt = 'selectHubPanel.notOnListButton.info.mindstorms.nxt', + SelectHubPanelNotOnListButtonInfoMindstormsEv3 = 'selectHubPanel.notOnListButton.info.mindstorms.ev3', + SelectHubPanelNotOnListButtonInfoPoweredUpTitle = 'selectHubPanel.notOnListButton.info.poweredUp.title', + SelectHubPanelNotOnListButtonInfoPoweredUpWedo2 = 'selectHubPanel.notOnListButton.info.poweredUp.wedo2', + SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain = 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain', + SelectHubPanelNotOnListButtonInfoPoweredUpMario = 'selectHubPanel.notOnListButton.info.poweredUp.mario', + SelectHubPanelNotOnListButtonInfoPoweredUpFootnote = 'selectHubPanel.notOnListButton.info.poweredUp.footnote', + LicensePanelTitle = 'licensePanel.title', + LicensePanelLicenseTextError = 'licensePanel.licenseText.error', + LicensePanelAcceptCheckboxLabel = 'licensePanel.acceptCheckbox.label', + OptionsPanelTitle = 'optionsPanel.title', + OptionsPanelHubNameLabel = 'optionsPanel.hubName.label', + OptionsPanelHubNameLabelInfo = 'optionsPanel.hubName.labelInfo', + OptionsPanelHubNameHelp = 'optionsPanel.hubName.help', + OptionsPanelHubNameError = 'optionsPanel.hubName.error', + OptionsPanelCustomMainLabel = 'optionsPanel.customMain.label', + OptionsPanelCustomMainLabelInfo = 'optionsPanel.customMain.labelInfo', + OptionsPanelCustomMainNotApplicableMessage = 'optionsPanel.customMain.notApplicable.message', + OptionsPanelCustomMainIncludeCurrentProgramLabel = 'optionsPanel.customMain.includeCurrentProgram.label', + OptionsPanelCustomMainIncludeCurrentProgramHelp = 'optionsPanel.customMain.includeCurrentProgram.help', + BootloaderPanelTitle = 'bootloaderPanel.title', + BootloaderPanelInstruction1 = 'bootloaderPanel.instruction1', + BootloaderPanelButtonBluetooth = 'bootloaderPanel.button.bluetooth', + BootloaderPanelButtonPower = 'bootloaderPanel.button.power', + BootloaderPanelLightBluetooth = 'bootloaderPanel.light.bluetooth', + BootloaderPanelLightStatus = 'bootloaderPanel.light.status', + BootloaderPanelLightPatternBluetooth = 'bootloaderPanel.lightPattern.bluetooth', + BootloaderPanelLightPatternStatus = 'bootloaderPanel.lightPattern.status', + BootloaderPanelStepDisconnectUsb = 'bootloaderPanel.step.disconnectUsb', + BootloaderPanelStepPowerOff = 'bootloaderPanel.step.powerOff', + BootloaderPanelStepDisconnectIo = 'bootloaderPanel.step.disconnectIo', + BootloaderPanelStepHoldButton = 'bootloaderPanel.step.holdButton', + BootloaderPanelStepConnectUsb = 'bootloaderPanel.step.connectUsb', + BootloaderPanelStepWaitForLight = 'bootloaderPanel.step.waitForLight', + BootloaderPanelStepReleaseButton = 'bootloaderPanel.step.releaseButton', + BootloaderPanelStepKeepHolding = 'bootloaderPanel.step.keepHolding', + BootloaderPanelInstruction2 = 'bootloaderPanel.instruction2', + NextButtonLabel = 'nextButton.label', + BackButtonLabel = 'backButton.label', + FlashFirmwareButtonLabel = 'flashFirmwareButton.label', +} diff --git a/src/firmware/installPybricksDialog/index.ts b/src/firmware/installPybricksDialog/index.ts new file mode 100644 index 00000000..2323fb34 --- /dev/null +++ b/src/firmware/installPybricksDialog/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +const encoder = new TextEncoder(); + +/** + * Validates the hub name. + * @param hubName The hub name. + * @returns True if the name if valid, otherwise false. + */ +export function validateHubName(hubName: string): boolean { + const encoded = encoder.encode(hubName); + + // Technically, the max hub name size is determined by each individual + // firmware file, so we can't check until the firmware has been selected. + // However all firmware currently have 16 bytes allocated (including zero- + // termination), so we can hard code the check here to allow notifying the + // user earlier for better UX. + return encoded.length < 16; +} diff --git a/src/firmware/installPybricksDialog/installPybricksDialog.scss b/src/firmware/installPybricksDialog/installPybricksDialog.scss new file mode 100644 index 00000000..3ea0a378 --- /dev/null +++ b/src/firmware/installPybricksDialog/installPybricksDialog.scss @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +.pb-firmware-installPybricksDialog { + &-body { + min-height: 250px; + } + + &-license { + display: flex; + flex-direction: column; + gap: 10px; + min-height: inherit; + + &-text { + flex-grow: 1; + min-height: 0; + max-height: 200px; + overflow: auto; + } + } +} diff --git a/src/firmware/installPybricksDialog/reducers.ts b/src/firmware/installPybricksDialog/reducers.ts new file mode 100644 index 00000000..afa2694a --- /dev/null +++ b/src/firmware/installPybricksDialog/reducers.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Reducer, combineReducers } from '@reduxjs/toolkit'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, + firmwareInstallPybricksDialogShow, +} from './actions'; + +/** Controls the flash Pybricks firmware dialog open state. */ +const isOpen: Reducer = (state = false, action) => { + if (firmwareInstallPybricksDialogShow.matches(action)) { + return true; + } + + if (firmwareInstallPybricksDialogAccept.matches(action)) { + return false; + } + + if (firmwareInstallPybricksDialogCancel.matches(action)) { + return false; + } + + return state; +}; + +export default combineReducers({ isOpen }); diff --git a/src/firmware/installPybricksDialog/translations/en.json b/src/firmware/installPybricksDialog/translations/en.json new file mode 100644 index 00000000..d8a7e673 --- /dev/null +++ b/src/firmware/installPybricksDialog/translations/en.json @@ -0,0 +1,90 @@ +{ + "title": "Install Pybricks Firmware", + "selectHubPanel": { + "title": "Select hub type", + "message": "Which kind of hub do you want to use?", + "notOnListButton": { + "label": "My hub is not in the list.", + "info": { + "mindstorms": { + "title": "MINDSTORMS Programmable Bricks", + "rcx": "RCX - not enough memory to run Pybricks", + "nxt": "NXT - maybe some day", + "ev3": "EV3 - supported using VS Code instead of Pybricks Code" + }, + "poweredUp": { + "title": "Unsupported Powered Up Hubs", + "wedo2": "WeDo 2.0 Smart hub", + "duploTrain": "Duplo Train hub", + "mario": "Mario/Luigi/Peach", + "footnote": "firmware cannot be updated" + } + } + } + }, + "licensePanel": { + "title": "Accept licenses", + "licenseText": { + "error": "There was a problem while getting the firmware file." + }, + "acceptCheckbox": { + "label": "I have read and agree to the license terms and conditions." + } + }, + "optionsPanel": { + "title": "Configure options", + "hubName": { + "label": "Hub name", + "labelInfo": "(optional)", + "help": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting.", + "error": "The name is too long." + }, + "customMain": { + "label": "Custom program", + "labelInfo": "(optional)", + "notApplicable": { + "message": "This hub has external flash memory so including a custom program when flashing firmware is not needed." + }, + "includeCurrentProgram": { + "label": "Include current program", + "help": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}" + } + } + }, + "bootloaderPanel": { + "title": "Place hub in bootloader mode", + "instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:", + "button": { + "bluetooth": "Bluetooth button", + "power": "power button" + }, + "light": { + "bluetooth": "Bluetooth light", + "status": "hub status light" + }, + "lightPattern": { + "bluetooth": "pink-green-blue-off", + "status": "light purple" + }, + "step": { + "disconnectUsb": "Disconnect the USB cable from the hub.", + "powerOff": "Turn off the hub.", + "disconnectIo": "Disconnect all motors and sensors from the I/O ports on the hub.", + "holdButton": "Press and hold the {button} on the hub.", + "connectUsb": "Connect the USB cable.", + "waitForLight": "Keep holding the {button} and wait for the {light} to start flashing {lightPattern}. This takes about 5 seconds.", + "releaseButton": "Release the {button}", + "keepHolding": "Keep holding the {button}." + }, + "instruction2": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware." + }, + "backButton": { + "label": "Back" + }, + "nextButton": { + "label": "Next" + }, + "flashFirmwareButton": { + "label": "Flash Firmware" + } +} diff --git a/src/firmware/reducers.test.ts b/src/firmware/reducers.test.ts index a910f8e0..c264aa26 100644 --- a/src/firmware/reducers.test.ts +++ b/src/firmware/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -17,6 +17,9 @@ test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "flashing": false, + "installPybricksDialog": Object { + "isOpen": false, + }, "progress": null, } `); diff --git a/src/firmware/reducers.ts b/src/firmware/reducers.ts index 97bd6c12..19ad63c1 100644 --- a/src/firmware/reducers.ts +++ b/src/firmware/reducers.ts @@ -3,6 +3,7 @@ import { Reducer, combineReducers } from 'redux'; import { didFailToFinish, didFinish, didProgress, didStart } from './actions'; +import installPybricksDialog from './installPybricksDialog/reducers'; const flashing: Reducer = (state = false, action) => { if (didStart.matches(action)) { @@ -28,4 +29,4 @@ const progress: Reducer = (state = null, action) => { return state; }; -export default combineReducers({ flashing, progress }); +export default combineReducers({ installPybricksDialog, flashing, progress }); diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 878e047c..2da5bcdb 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -62,8 +62,14 @@ import { didFinish, didProgress, didStart, + firmwareInstallPybricks, flashFirmware, } from './actions'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, + firmwareInstallPybricksDialogShow, +} from './installPybricksDialog/actions'; const firmwareZipMap = new Map([ [HubType.CityHub, cityHubZip], @@ -477,6 +483,23 @@ function* handleFlashFirmware(action: ReturnType): Generat } } +function* handleInstallPybricks(): Generator { + yield* put(firmwareInstallPybricksDialogShow()); + const { accepted, canceled } = yield* race({ + accepted: take(firmwareInstallPybricksDialogAccept), + canceled: take(firmwareInstallPybricksDialogCancel), + }); + + if (canceled) { + return; + } + + defined(accepted); + + yield* put(flashFirmware(accepted.firmwareZip, false, accepted.hubName)); +} + export default function* (): Generator { yield* takeEvery(flashFirmware, handleFlashFirmware); + yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks); } diff --git a/src/settings/Settings.test.tsx b/src/settings/Settings.test.tsx index 59e2f381..5f19301d 100644 --- a/src/settings/Settings.test.tsx +++ b/src/settings/Settings.test.tsx @@ -4,6 +4,7 @@ import { cleanup, getByLabelText, waitFor } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../test'; +import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions'; import Settings from './Settings'; afterEach(() => { @@ -41,41 +42,27 @@ describe('darkMode setting switch', () => { }); }); -describe('flashCurrentProgram setting switch', () => { - it('should toggle the setting', async () => { - const [user, settings] = testRender(); +describe('firmware', () => { + it('should dispatch action when install Pybricks firmware button is clicked', async () => { + const [user, settings, dispatch] = testRender(); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe(null); + const button = settings.getByRole('button', { + name: 'Install Pybricks Firmware', + }); + await user.click(button); - await user.click(settings.getByLabelText('Include current program')); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('true'); - - await user.click(settings.getByLabelText('Include current program')); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('false'); - }); -}); - -describe('hubName setting', () => { - it('should migrate old settings', () => { - // old settings did not use json format, so lack quotes - localStorage.setItem('setting.hubName', 'old name'); - - const [, settings] = testRender(); - - const textBox = settings.getByLabelText('Hub name'); - - expect(textBox).toHaveValue('old name'); + expect(dispatch).toHaveBeenCalledWith(firmwareInstallPybricks()); }); - it('should update the setting', async () => { - const [user, settings] = testRender(); + it('should dispatch action when restore official LEGO firmware button is clicked', async () => { + const [user, settings, dispatch] = testRender(); - expect(localStorage.getItem('setting.hubName')).toBe(null); + const button = settings.getByRole('button', { + name: 'Restore Official LEGO® Firmware', + }); + await user.click(button); - const textBox = settings.getByLabelText('Hub name'); - await user.type(textBox, 'test name'); - - expect(localStorage.getItem('setting.hubName')).toBe('"test name"'); + expect(dispatch).toHaveBeenCalledWith(firmwareRestoreLego()); }); }); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index ccd28ac9..67c5aca2 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -6,10 +6,6 @@ import { ButtonGroup, ControlGroup, FormGroup, - Icon, - InputGroup, - Intent, - Label, Switch, } from '@blueprintjs/core'; import React, { useState } from 'react'; @@ -18,7 +14,6 @@ import { useTernaryDarkMode } from 'usehooks-ts'; import AboutDialog from '../about/AboutDialog'; import { appCheckForUpdate, appReload, appShowInstallPrompt } from '../app/actions'; import { - appName, pybricksBugReportsUrl, pybricksGitterUrl, pybricksProjectsUrl, @@ -26,15 +21,13 @@ import { } from '../app/constants'; import { Button } from '../components/Button'; import HelpButton from '../components/HelpButton'; +import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions'; +import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; import { pseudolocalize } from '../i18n'; import { useSelector } from '../reducers'; import ExternalLinkIcon from '../utils/ExternalLinkIcon'; import { isMacOS } from '../utils/os'; -import { - useSettingFlashCurrentProgram, - useSettingHubName, - useSettingIsShowDocsEnabled, -} from './hooks'; +import { useSettingIsShowDocsEnabled } from './hooks'; import { I18nId, useI18n } from './i18n'; import './settings.scss'; @@ -44,8 +37,6 @@ const Settings: React.VoidFunctionComponent = () => { const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false); const { isDarkMode, setTernaryDarkMode } = useTernaryDarkMode(); - const [isFlashCurrentProgramEnabled, setIsFlashCurrentProgramEnabled] = - useSettingFlashCurrentProgram(); const isServiceWorkerRegistered = useSelector( (s) => s.app.isServiceWorkerRegistered, ); @@ -56,7 +47,6 @@ const Settings: React.VoidFunctionComponent = () => { ); const promptingInstall = useSelector((s) => s.app.promptingInstall); const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse); - const { hubName, isHubNameValid, setHubName } = useSettingHubName(); const dispatch = useDispatch(); @@ -107,52 +97,19 @@ const Settings: React.VoidFunctionComponent = () => { - - - setIsFlashCurrentProgramEnabled( - (e.target as HTMLInputElement).checked, - ) - } - /> - - - - - setHubName(e.currentTarget.value)} - onMouseOver={(e) => e.preventDefault()} - onMouseDown={(e) => e.stopPropagation()} - intent={isHubNameValid ? Intent.NONE : Intent.DANGER} - placeholder="Pybricks Hub" - rightElement={ - isHubNameValid ? undefined : ( - - ) - } - /> - - +