diff --git a/webclient/integration/src/websocket/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts index 1903bb75d..78a366f71 100644 --- a/webclient/integration/src/websocket/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -100,7 +100,12 @@ describe('connection lifecycle', () => { vi.advanceTimersByTime(5000); + // Fire onclose the way a real browser would when the connection-attempt + // timer closes a still-connecting socket. + mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); + expect(mock.close).toHaveBeenCalled(); + // Never-opened sockets bypass reconnect and land on DISCONNECTED directly. expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); @@ -111,12 +116,15 @@ describe('connection lifecycle', () => { const mock = getMockWebSocket(); getWebClient().disconnect(); + // The transport schedules close() synchronously; onclose follows in the + // browser event loop. Simulate it so the status transition fires. + mock.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent); expect(mock.close).toHaveBeenCalled(); expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); - it('drops pending commands and clears state on unexpected socket close', () => { + it('enters RECONNECTING on unexpected socket close after a successful handshake', () => { connectAndHandshake(); // A login command is now pending (sent during handshake) @@ -127,6 +135,8 @@ describe('connection lifecycle', () => { mock.readyState = 3; mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); - expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); + // With reconnect configured, a drop after a successful open enters the + // reconnect state machine rather than going straight to DISCONNECTED. + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.RECONNECTING); }); }); \ No newline at end of file diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index ece8a1891..e94f9d2bf 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -15,11 +15,20 @@ interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap { ForgotPasswordResetParams: Omit; } +const CONNECTING_STATUS_LABEL = 'Connecting...'; + +function beginConnect( + options: { host: string; port: string | number }, + reason: WebsocketTypes.WebSocketConnectReason, +): void { + setPendingOptions({ ...options, reason }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, CONNECTING_STATUS_LABEL); + WebClient.instance.connect({ host: options.host, port: options.port }); +} + export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest { login(options: Omit): void { - setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); - WebClient.instance.connect({ host: options.host, port: options.port }); + beginConnect(options, WebsocketTypes.WebSocketConnectReason.LOGIN); } testConnection(options: Omit): void { @@ -27,33 +36,23 @@ export class AuthenticationRequestImpl implements WebsocketTypes.IAuthentication } register(options: Omit): void { - setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); - WebClient.instance.connect({ host: options.host, port: options.port }); + beginConnect(options, WebsocketTypes.WebSocketConnectReason.REGISTER); } activateAccount(options: Omit): void { - setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); - WebClient.instance.connect({ host: options.host, port: options.port }); + beginConnect(options, WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT); } resetPasswordRequest(options: Omit): void { - setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); - WebClient.instance.connect({ host: options.host, port: options.port }); + beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST); } resetPasswordChallenge(options: Omit): void { - setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); - WebClient.instance.connect({ host: options.host, port: options.port }); + beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE); } resetPassword(options: Omit): void { - setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); - WebClient.instance.connect({ host: options.host, port: options.port }); + beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET); } disconnect(): void { diff --git a/webclient/src/common.i18n.json b/webclient/src/common.i18n.json index 64a102c38..ebb272616 100644 --- a/webclient/src/common.i18n.json +++ b/webclient/src/common.i18n.json @@ -3,6 +3,7 @@ "language": "English", "disconnect": "Disconnect", "label": { + "confirmEmail": "Confirm Email", "confirmPassword": "Confirm Password", "confirmSure": "Are you sure?", "country": "Country", @@ -19,6 +20,7 @@ "username": "Username" }, "validation": { + "emailsMustMatch": "Emails don't match", "minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required", "passwordsMustMatch": "Passwords don't match", "required": "Required" diff --git a/webclient/src/components/Card/Card.tsx b/webclient/src/components/Card/Card.tsx index 83af90e1f..d1e25c8b3 100644 --- a/webclient/src/components/Card/Card.tsx +++ b/webclient/src/components/Card/Card.tsx @@ -1,6 +1,3 @@ -// eslint-disable-next-line -import React, { useMemo, useState } from 'react'; - import { CardDTO } from '@app/services'; import './Card.css'; @@ -10,11 +7,13 @@ interface CardProps { } const Card = ({ card }: CardProps) => { - const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`; + if (!card) { + return null; + } - return card && ( - {card?.name} - ); -} + const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`; + + return {card.name}; +}; export default Card; diff --git a/webclient/src/components/CardDetails/CardDetails.tsx b/webclient/src/components/CardDetails/CardDetails.tsx index be7ed6518..7899c6a8b 100644 --- a/webclient/src/components/CardDetails/CardDetails.tsx +++ b/webclient/src/components/CardDetails/CardDetails.tsx @@ -30,7 +30,7 @@ const CardDetails = ({ card }: CardProps) => { (!card.power && !card.toughness) ? null : (
P/T: - {card.power || 0}/{card.toughness || 0} + {card.power ?? 0}/{card.toughness ?? 0}
) } diff --git a/webclient/src/components/CheckboxField/CheckboxField.tsx b/webclient/src/components/CheckboxField/CheckboxField.tsx index 562687489..388f26d9b 100644 --- a/webclient/src/components/CheckboxField/CheckboxField.tsx +++ b/webclient/src/components/CheckboxField/CheckboxField.tsx @@ -1,21 +1,28 @@ -import React from 'react'; -import Checkbox from '@mui/material/Checkbox'; +import Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; -const CheckboxField = (props) => { - const { input: { value, onChange }, label, ...args } = props; +import type { FinalFormFieldProps } from '../fieldTypes'; + +type CheckboxFieldProps = FinalFormFieldProps & { + label?: string; +} & Omit; + +const CheckboxField = ({ input, meta: _meta, label, ...args }: CheckboxFieldProps) => { + const { value, onChange, onBlur, onFocus, name } = input; - // @TODO this isnt unchecking properly return ( onChange(checked)} + name={name} + checked={Boolean(value)} + onChange={onChange} + onBlur={onBlur} + onFocus={onFocus} color="primary" /> } diff --git a/webclient/src/components/CountryDropdown/CountryDropdown.tsx b/webclient/src/components/CountryDropdown/CountryDropdown.tsx index 5a7d519bf..7112e35d3 100644 --- a/webclient/src/components/CountryDropdown/CountryDropdown.tsx +++ b/webclient/src/components/CountryDropdown/CountryDropdown.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { Select, MenuItem } from '@mui/material'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; @@ -8,49 +7,48 @@ import { useLocaleSort } from '@app/hooks'; import { Images } from '@app/images'; import { App } from '@app/types'; +import type { FinalFormFieldProps } from '../fieldTypes'; import './CountryDropdown.css'; -const CountryDropdown = ({ input: { onChange } }) => { - const [value, setValue] = useState(''); +type CountryDropdownProps = FinalFormFieldProps; + +const CountryDropdown = ({ input }: CountryDropdownProps) => { const { t } = useTranslation(); + const currentValue = (input.value as string | undefined) ?? ''; - useEffect(() => onChange(value), [value]); - - const translateCountry = country => t(`Common.countries.${country}`); + const translateCountry = (country: string) => t(`Common.countries.${country}`); const sortedCountries = useLocaleSort(App.countryCodes, translateCountry); return ( - - Country + + Country - ) + ); }; export default CountryDropdown; diff --git a/webclient/src/components/Game/CardSlot/CardSlot.tsx b/webclient/src/components/Game/CardSlot/CardSlot.tsx index 312da72c5..15ab4e605 100644 --- a/webclient/src/components/Game/CardSlot/CardSlot.tsx +++ b/webclient/src/components/Game/CardSlot/CardSlot.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import type { Data } from '@app/types'; import { cx } from '@app/utils'; @@ -94,4 +96,4 @@ function CardSlot({ ); } -export default CardSlot; +export default memo(CardSlot); diff --git a/webclient/src/components/Game/GameLog/GameLog.tsx b/webclient/src/components/Game/GameLog/GameLog.tsx index c58a76cb3..a2c72d051 100644 --- a/webclient/src/components/Game/GameLog/GameLog.tsx +++ b/webclient/src/components/Game/GameLog/GameLog.tsx @@ -37,7 +37,7 @@ function GameLog({ gameId }: GameLogProps) { const name = players?.[m.playerId]?.properties.userInfo?.name ?? `p${m.playerId}`; const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line'; return ( -
+
{!isEvent && {name}:} {m.message}
diff --git a/webclient/src/components/Game/TurnControls/useTurnControls.ts b/webclient/src/components/Game/TurnControls/useTurnControls.ts index a42f31b20..b73298577 100644 --- a/webclient/src/components/Game/TurnControls/useTurnControls.ts +++ b/webclient/src/components/Game/TurnControls/useTurnControls.ts @@ -3,6 +3,12 @@ import { useMemo, useState } from 'react'; import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks'; import { GameSelectors, useAppSelector } from '@app/store'; +/** + * MTG turn phase count (0..10). Mirrors desktop's wrap-around behavior in + * `GameView::actNextPhase` — see `types/game.ts` for the Phase enum. + */ +const PHASE_COUNT = 11; + export interface TurnControlsOpponent { playerId: number; name: string; @@ -119,11 +125,11 @@ export function useTurnControls({ if (!canAdvance || !hasLiveGame) { return; } - // Desktop wraps at 11 → 0 (the Phase enum is 0–10). When no phase is - // active yet (activePhase < 0 during the pre-game lobby), advance to + // Desktop wraps at PHASE_COUNT → 0 (the Phase enum is 0–10). When no phase + // is active yet (activePhase < 0 during the pre-game lobby), advance to // Untap (0). const current = game.activePhase; - const next = current >= 0 ? (current + 1) % 11 : 0; + const next = current >= 0 ? (current + 1) % PHASE_COUNT : 0; webClient.request.game.setActivePhase(gameId, { phase: next }); }; diff --git a/webclient/src/components/Guard/AuthGuard.tsx b/webclient/src/components/Guard/AuthGuard.tsx index 897556613..43051a5e9 100644 --- a/webclient/src/components/Guard/AuthGuard.tsx +++ b/webclient/src/components/Guard/AuthGuard.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Navigate } from 'react-router-dom'; import { ServerSelectors, useAppSelector } from '@app/store'; diff --git a/webclient/src/components/Guard/ModGuard.tsx b/webclient/src/components/Guard/ModGuard.tsx index 96844b436..5c6bdf529 100644 --- a/webclient/src/components/Guard/ModGuard.tsx +++ b/webclient/src/components/Guard/ModGuard.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Navigate } from 'react-router-dom'; import { ServerSelectors, useAppSelector } from '@app/store'; diff --git a/webclient/src/components/InputAction/InputAction.css b/webclient/src/components/InputAction/InputAction.css index 25e784a3a..269d8a3f2 100644 --- a/webclient/src/components/InputAction/InputAction.css +++ b/webclient/src/components/InputAction/InputAction.css @@ -12,7 +12,7 @@ .input-action__item { width: 100%; - height: 100%; + height: 100%; } .input-action__item > div { margin: 0; diff --git a/webclient/src/components/InputAction/InputAction.tsx b/webclient/src/components/InputAction/InputAction.tsx index 7326c0a35..6cccbab46 100644 --- a/webclient/src/components/InputAction/InputAction.tsx +++ b/webclient/src/components/InputAction/InputAction.tsx @@ -1,12 +1,25 @@ -import React from 'react'; -import { Field } from 'react-final-form' +import { Field } from 'react-final-form'; import Button from '@mui/material/Button'; import { InputField } from '..'; import './InputAction.css'; -const InputAction = ({ action, label, name, validate = () => false, disabled = false }) => ( +interface InputActionProps { + action: string; + label: string; + name: string; + validate?: (value: unknown) => string | undefined | false; + disabled?: boolean; +} + +const InputAction = ({ + action, + label, + name, + validate = () => undefined, + disabled = false, +}: InputActionProps) => (
diff --git a/webclient/src/components/InputField/InputField.tsx b/webclient/src/components/InputField/InputField.tsx index 3299ba383..8a8ead4ae 100644 --- a/webclient/src/components/InputField/InputField.tsx +++ b/webclient/src/components/InputField/InputField.tsx @@ -1,57 +1,57 @@ -import React from 'react'; import { styled } from '@mui/material/styles'; -import TextField from '@mui/material/TextField'; +import TextField, { TextFieldProps } from '@mui/material/TextField'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; +import type { FinalFormFieldProps } from '../fieldTypes'; + import './InputField.css'; const PREFIX = 'InputField'; const classes = { - root: `${PREFIX}-root` + root: `${PREFIX}-root`, }; const Root = styled('div')(({ theme }) => ({ [`&.${classes.root}`]: { '& .InputField-error': { - color: theme.palette.error.main + color: theme.palette.error.main, }, - '& .InputField-warning': { - color: theme.palette.warning.main + color: theme.palette.warning.main, }, }, })); -const InputField = ({ input, meta, ...args }) => { +type InputFieldProps = + FinalFormFieldProps & + Omit; + +const InputField = ({ input, meta, ...args }: InputFieldProps) => { const { touched, error, warning } = meta; return ( - - { touched && ( + + {touched && (
- { - (error && -
- {error} - -
- ) || - - (warning &&
{warning}
) - } + {(error && +
+ {error} + +
+ ) || (warning &&
{warning}
)}
- ) } + )}
); diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index f7e122c10..0717db31c 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -1,6 +1,6 @@ import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; -import { Select, MenuItem } from '@mui/material'; +import { Select, MenuItem, SelectChangeEvent } from '@mui/material'; import Button from '@mui/material/Button'; import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; @@ -14,8 +14,8 @@ import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; import { KnownHostDialog } from '@app/dialogs'; import { getHostPort, HostDTO } from '@app/services'; -import Toast from '../Toast/Toast'; +import type { FinalFormFieldProps } from '../fieldTypes'; import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent'; import './KnownHosts.css'; @@ -50,9 +50,11 @@ const Root = styled('div')(({ theme }) => ({ }, })); -const KnownHosts = (props: any) => { - const { input, meta, disabled } = props; - const onChange: (value: HostDTO) => void = input.onChange; +type KnownHostsProps = FinalFormFieldProps & { + disabled?: boolean; +}; + +const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => { const { touched, error, warning } = meta; const { t } = useTranslation(); @@ -61,22 +63,25 @@ const KnownHosts = (props: any) => { selectedHost, testingConnection, dialogState, - showCreateToast, - showDeleteToast, - showEditToast, - setShowCreateToast, - setShowDeleteToast, - setShowEditToast, onPick, openAddKnownHostDialog, openEditKnownHostDialog, closeKnownHostDialog, handleDialogRemove, handleDialogSubmit, - } = useKnownHostsComponent({ onChange }); + } = useKnownHostsComponent({ onChange: input.onChange }); + + const selectedId = selectedHost?.id ?? ''; + + const handleSelectChange = (event: SelectChangeEvent) => { + const value = event.target.value; + if (typeof value === 'number') { + void onPick(value); + } + }; return ( - + {touched && (
@@ -97,24 +102,24 @@ const KnownHosts = (props: any) => { label="Host" margin="dense" name="host" - value={selectedHost ?? ''} - fullWidth={true} - onChange={(e) => onPick(e.target.value as unknown as HostDTO)} + value={selectedId} + fullWidth + onChange={handleSelectChange} disabled={disabled} > - - {hosts.map((host, index) => { + {hosts.map((host) => { const hostPort = getHostPort(host); return ( - +
-
+
{testingConnection === TestConnection.FAILED ? ( ) : ( @@ -151,20 +156,11 @@ const KnownHosts = (props: any) => { - setShowCreateToast(false)}> - {t('KnownHosts.toast', { mode: 'created' })} - - setShowDeleteToast(false)}> - {t('KnownHosts.toast', { mode: 'deleted' })} - - setShowEditToast(false)}> - {t('KnownHosts.toast', { mode: 'edited' })} - ); }; diff --git a/webclient/src/components/KnownHosts/useKnownHostsComponent.ts b/webclient/src/components/KnownHosts/useKnownHostsComponent.ts index 9f6017a8f..26508a5a8 100644 --- a/webclient/src/components/KnownHosts/useKnownHostsComponent.ts +++ b/webclient/src/components/KnownHosts/useKnownHostsComponent.ts @@ -1,5 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useToast } from '@app/components'; import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { getHostPort, HostDTO } from '@app/services'; import { ServerTypes } from '@app/store'; @@ -16,13 +18,7 @@ export interface KnownHostsComponent { selectedHost: App.Host | undefined; testingConnection: TestConnection | null; dialogState: { open: boolean; edit: HostDTO | null }; - showCreateToast: boolean; - showDeleteToast: boolean; - showEditToast: boolean; - setShowCreateToast: (v: boolean) => void; - setShowDeleteToast: (v: boolean) => void; - setShowEditToast: (v: boolean) => void; - onPick: (host: HostDTO) => Promise; + onPick: (id: number) => Promise; openAddKnownHostDialog: () => void; openEditKnownHostDialog: (host: HostDTO) => void; closeKnownHostDialog: () => void; @@ -39,11 +35,20 @@ export interface UseKnownHostsComponentArgs { onChange: (value: HostDTO) => void; } +type ToastMode = 'created' | 'deleted' | 'edited'; + export function useKnownHostsComponent({ onChange, }: UseKnownHostsComponentArgs): KnownHostsComponent { const webClient = useWebClient(); const knownHosts = useKnownHosts(); + const { t } = useTranslation(); + + const [toastMode, setToastMode] = useState('created'); + const knownHostToast = useToast({ + key: 'known-hosts-action', + children: t('KnownHosts.toast', { mode: toastMode }), + }); const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({ open: false, @@ -51,16 +56,16 @@ export function useKnownHostsComponent({ }); const [testingConnection, setTestingConnection] = useState(null); - - const [showCreateToast, setShowCreateToast] = useState(false); - const [showDeleteToast, setShowDeleteToast] = useState(false); - const [showEditToast, setShowEditToast] = useState(false); + // Tracks the host currently awaiting a testConnection response. If null when a + // response arrives, the caller has moved on — ignore the stale reply. + const pendingTestRef = useRef(null); const selectedHost = knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined; const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : []; const testConnection = (host: HostDTO) => { + pendingTestRef.current = host; setTestingConnection(TestConnection.TESTING); webClient.request.authentication.testConnection({ ...getHostPort(host) }); }; @@ -73,28 +78,37 @@ export function useKnownHostsComponent({ testConnection(selectedHost); }, [selectedHost]); - useReduxEffect( - () => { - setTestingConnection(TestConnection.SUCCESS); - }, - ServerTypes.TEST_CONNECTION_SUCCESSFUL, - [], - ); + useReduxEffect(() => { + if (!pendingTestRef.current) { + return; + } + setTestingConnection(TestConnection.SUCCESS); + pendingTestRef.current = null; + }, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []); - useReduxEffect( - () => { - setTestingConnection(TestConnection.FAILED); - }, - ServerTypes.TEST_CONNECTION_FAILED, - [], - ); + useReduxEffect(() => { + if (!pendingTestRef.current) { + return; + } + setTestingConnection(TestConnection.FAILED); + pendingTestRef.current = null; + }, ServerTypes.TEST_CONNECTION_FAILED, []); - const onPick = async (host: HostDTO) => { + const fireToast = (mode: ToastMode) => { + setToastMode(mode); + knownHostToast.openToast(); + }; + + const onPick = async (id: number) => { if (knownHosts.status !== LoadingState.READY) { return; } + const host = knownHosts.value?.hosts.find((h) => h.id === id); + if (!host) { + return; + } onChange(host); - await knownHosts.select(host.id!); + await knownHosts.select(id); testConnection(host); }; @@ -116,7 +130,7 @@ export function useKnownHostsComponent({ } await knownHosts.remove(id); closeKnownHostDialog(); - setShowDeleteToast(true); + fireToast('deleted'); }; const handleDialogSubmit = async ({ @@ -136,11 +150,11 @@ export function useKnownHostsComponent({ if (id) { await knownHosts.update(id, { name, host, port }); - setShowEditToast(true); + fireToast('edited'); } else { const newHost: App.Host = { name, host, port, editable: true }; await knownHosts.add(newHost); - setShowCreateToast(true); + fireToast('created'); } closeKnownHostDialog(); @@ -151,12 +165,6 @@ export function useKnownHostsComponent({ selectedHost, testingConnection, dialogState, - showCreateToast, - showDeleteToast, - showEditToast, - setShowCreateToast, - setShowDeleteToast, - setShowEditToast, onPick, openAddKnownHostDialog, openEditKnownHostDialog, diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.css b/webclient/src/components/LanguageDropdown/LanguageDropdown.css index c4db3d0d0..1af4f4efa 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.css +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.css @@ -1,6 +1,3 @@ -.LanguageDropdown { -} - .LanguageDropdown-item { display: flex; align-items: center; diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index d98defb2b..cadc715fc 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -1,7 +1,5 @@ - -import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Select, MenuItem } from '@mui/material'; +import { Select, MenuItem, SelectChangeEvent } from '@mui/material'; import FormControl from '@mui/material/FormControl'; import { Images } from '@app/images'; @@ -11,48 +9,43 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - // i18next `resolvedLanguage` is undefined until a registered resource matches; - // MUI Select requires a concrete, in-range value. - const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); + const currentLanguage = i18n.resolvedLanguage ?? i18n.language ?? ''; - useEffect(() => { - if (language !== i18n.resolvedLanguage) { - i18n.changeLanguage(language); + const onLanguageChange = (event: SelectChangeEvent) => { + const next = event.target.value as App.Language; + if (next !== currentLanguage) { + void i18n.changeLanguage(next); } - }, [language]); + }; return ( - + - ) + ); }; export default LanguageDropdown; diff --git a/webclient/src/components/Message/CardCallout.tsx b/webclient/src/components/Message/CardCallout.tsx index b9b34634e..ac80b366f 100644 --- a/webclient/src/components/Message/CardCallout.tsx +++ b/webclient/src/components/Message/CardCallout.tsx @@ -25,7 +25,11 @@ const Root = styled('span')(() => ({ } })); -const CardCallout = ({ name }) => { +interface CardCalloutProps { + name: string; +} + +const CardCallout = ({ name }: CardCalloutProps) => { const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } = useCardCallout(name); diff --git a/webclient/src/components/Message/Message.tsx b/webclient/src/components/Message/Message.tsx index c818cc991..ad3466e07 100644 --- a/webclient/src/components/Message/Message.tsx +++ b/webclient/src/components/Message/Message.tsx @@ -7,7 +7,15 @@ import CardCallout from './CardCallout'; import { useParsedMessage } from './useMessage'; import './Message.css'; -const Message = ({ message: { message } }) => ( +interface MessagePayload { + message: string; +} + +interface MessageProps { + message: MessagePayload; +} + +const Message = ({ message: { message } }: MessageProps) => (
@@ -15,7 +23,11 @@ const Message = ({ message: { message } }) => (
); -const ParsedMessage = ({ message }) => { +interface ParsedMessageProps { + message: string; +} + +const ParsedMessage = ({ message }: ParsedMessageProps) => { const { name, chunks } = useParsedMessage(message, parseChunks); return ( @@ -26,7 +38,12 @@ const ParsedMessage = ({ message }) => { ); }; -const PlayerLink = ({ name, label = name }) => ( +interface PlayerLinkProps { + name: string; + label?: string; +} + +const PlayerLink = ({ name, label = name }: PlayerLinkProps) => ( {label} @@ -69,7 +86,7 @@ function parseMentionChunk(chunk: string): ReactNode { if (mention) { const name = mention[0].substr(1); - return (); + return (); } return mentionChunk; diff --git a/webclient/src/components/Message/useMessage.ts b/webclient/src/components/Message/useMessage.ts index b8e28a427..1139adadc 100644 --- a/webclient/src/components/Message/useMessage.ts +++ b/webclient/src/components/Message/useMessage.ts @@ -1,27 +1,25 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; import { App } from '@app/types'; export interface ParsedMessage { name: string | null; - chunks: ReactNode[] | null; + chunks: ReactNode[]; } export type ChunkParser = (chunk: string, index: number) => ReactNode; +// `parseChunk` must be a stable reference across renders (module-level function +// or `useCallback`). Passing a fresh closure every render will thrash the memo. export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage { - const [chunks, setChunks] = useState(null); - const [name, setName] = useState(null); - - useEffect(() => { + return useMemo(() => { const match = message.match(App.MESSAGE_SENDER_REGEX); - if (match) { - setName(match[1]); - } - setChunks(parseMessage(message, parseChunk)); + const name = match ? match[1] : null; + return { + name, + chunks: parseMessage(message, parseChunk), + }; }, [message, parseChunk]); - - return { name, chunks }; } export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] { diff --git a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx index 95f10f62c..2f952f021 100644 --- a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx +++ b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx @@ -1,24 +1,23 @@ -import React, { useEffect, useRef } from 'react'; +import { ReactNode, useEffect, useRef } from 'react'; -const ScrollToBottomOnChanges = ({ content, changes }) => { - const messagesEndRef = useRef(null); +interface ScrollToBottomOnChangesProps { + content: ReactNode; + changes: unknown; +} - const scrollToBottom = () => { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) - } +const ScrollToBottomOnChanges = ({ content, changes }: ScrollToBottomOnChangesProps) => { + const messagesEndRef = useRef(null); - useEffect(scrollToBottom, [changes]); - - const styling = { - height: '100%' - }; + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [changes]); return ( -
+
{content}
- ) -} + ); +}; export default ScrollToBottomOnChanges; diff --git a/webclient/src/components/SelectField/SelectField.tsx b/webclient/src/components/SelectField/SelectField.tsx index fdbef0e9c..d01ba7e19 100644 --- a/webclient/src/components/SelectField/SelectField.tsx +++ b/webclient/src/components/SelectField/SelectField.tsx @@ -1,14 +1,29 @@ -import React from 'react'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; +import type { FinalFormFieldProps } from '../fieldTypes'; + import './SelectField.css'; -const SelectField = ({ input, label, options, value }) => { - const id = label + '-select-field'; - const labelId = id + '-label'; +export interface SelectFieldOption { + value: V; + label: string; +} + +interface SelectFieldProps extends FinalFormFieldProps { + label: string; + options: SelectFieldOption[]; +} + +const SelectField = ({ + input, + label, + options, +}: SelectFieldProps) => { + const id = `${label}-select-field`; + const labelId = `${id}-label`; return ( @@ -16,13 +31,15 @@ const SelectField = ({ input, label, options, value }) => { + label={label} + {...input} + > + {options.map(option => ( + + {option.label} + + ))} + ); }; diff --git a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css index f439dee5c..19597eacd 100644 --- a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css +++ b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css @@ -12,7 +12,7 @@ .three-pane-layout .grid-main { display: flex; - flex-direction: column; + flex-direction: column; } .three-pane-layout .grid-main__top { diff --git a/webclient/src/components/Toast/Toast.tsx b/webclient/src/components/Toast/Toast.tsx index 0578016ef..2f69869cb 100644 --- a/webclient/src/components/Toast/Toast.tsx +++ b/webclient/src/components/Toast/Toast.tsx @@ -1,35 +1,34 @@ -import * as React from 'react' -import { createPortal } from 'react-dom' +import { ReactNode, SyntheticEvent } from 'react'; -import Alert from '@mui/material/Alert'; +import Alert, { AlertColor } from '@mui/material/Alert'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import Slide from '@mui/material/Slide'; +import Slide, { SlideProps } from '@mui/material/Slide'; import Snackbar from '@mui/material/Snackbar'; const iconMapping = { - success: + success: , +}; + +export interface ToastProps { + open: boolean; + onClose: (event?: SyntheticEvent) => void; + severity?: AlertColor; + autoHideDuration?: number; + children?: ReactNode; } -function Toast(props) { - const { open, onClose, severity = 'success', autoHideDuration = 10000, children } = props - - const rootElemRef = React.useRef(document.createElement('div')); - - React.useEffect(() => { - document.body.appendChild(rootElemRef.current) - return () => { - rootElemRef.current.remove(); - } - }, [rootElemRef]) - - const handleClose = (event?: React.SyntheticEvent, reason?: string) => { +// MUI's Snackbar already self-portals to the end of document.body; adding our +// own createPortal wrapper would leak
s under React StrictMode's double- +// invoked effects. Render the Snackbar directly. +function Toast({ open, onClose, severity = 'success', autoHideDuration = 10000, children }: ToastProps) { + const handleClose = (event?: SyntheticEvent | Event, reason?: string) => { if (reason === 'clickaway') { return; } - onClose(event); + onClose(event as SyntheticEvent | undefined); }; - const node = ( + return ( - - ) - if (!rootElemRef.current) { - return null - } - - return createPortal( - node, - rootElemRef.current ); } -function TransitionLeft(props) { +function TransitionLeft(props: SlideProps) { return ; } -export default Toast +export default Toast; diff --git a/webclient/src/components/Toast/ToastContext.tsx b/webclient/src/components/Toast/ToastContext.tsx index bcca0deff..9978c1680 100644 --- a/webclient/src/components/Toast/ToastContext.tsx +++ b/webclient/src/components/Toast/ToastContext.tsx @@ -1,71 +1,77 @@ -import { createContext, FC, PropsWithChildren, ReactChild, ReactNode, useContext, useEffect, useReducer, Context } from 'react' +import { createContext, FC, PropsWithChildren, ReactNode, useContext, useEffect, useReducer } from 'react'; -import { ACTIONS, initialState, reducer } from './reducer'; -import Toast from './Toast' +import { ACTIONS, initialState, reducer, ToastEntry } from './reducer'; +import Toast from './Toast'; -interface ToastEntry { - isOpen: boolean, - children: ReactChild, +interface ToastContextValue { + toasts: Record; + addToast: (key: string, children: ReactNode) => void; + openToast: (key: string) => void; + closeToast: (key: string) => void; + removeToast: (key: string) => void; } -interface ToastState { - toasts: Record, - addToast: (key, children) => void, - openToast: (key) => void, - closeToast: (key) => void, - removeToast: (key) => void, -} - -const ToastContext: Context = createContext({ +const ToastContext = createContext({ toasts: {}, - addToast: (_key, _children) => {}, - openToast: (_key) => {}, - closeToast: (_key) => {}, - removeToast: (_key) => {}, + addToast: () => {}, + openToast: () => {}, + closeToast: () => {}, + removeToast: () => {}, }); -export const ToastProvider: FC = (props) => { - const { children } = props - const [state, dispatch] = useReducer(reducer, initialState) - const providerState = { +export const ToastProvider: FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + const providerState: ToastContextValue = { toasts: state.toasts, - addToast: (key, children) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children } }), - openToast: key => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }), - closeToast: key => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }), - removeToast: key => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }), - } + addToast: (key, toastChildren) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children: toastChildren } }), + openToast: (key) => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }), + closeToast: (key) => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }), + removeToast: (key) => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }), + }; return ( {children}
- {Object.entries(state.toasts).map(([key, value]: [string, ToastEntry]) => { - const { isOpen, children } = value; - return ( - dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}> - {children} - - ) - })} + {Object.entries(state.toasts).map(([key, entry]) => ( + dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })} + > + {entry.children} + + ))}
- ) -} + ); +}; export interface ToastHookOptions { - key: string, - children: ReactNode + key: string; + children: ReactNode; } -export function useToast({ key, children }) { - const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext) +export interface ToastHandle { + openToast: () => void; + closeToast: () => void; + removeToast: () => void; +} +export function useToast({ key, children }: ToastHookOptions): ToastHandle { + const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext); + + // Toast children are captured at registration; re-registering every render + // would churn provider state. Intentional mount/unmount-only effect keyed on `key`. useEffect(() => { - addToast(key, children) - }, []) + addToast(key, children); + return () => { + removeToast(key); + }; + }, [key]); return { openToast: () => openToast(key), closeToast: () => closeToast(key), removeToast: () => removeToast(key), - } + }; } diff --git a/webclient/src/components/Toast/reducer.ts b/webclient/src/components/Toast/reducer.ts index 3f600db52..e7b1da72f 100644 --- a/webclient/src/components/Toast/reducer.ts +++ b/webclient/src/components/Toast/reducer.ts @@ -1,61 +1,88 @@ +import type { ReactNode } from 'react'; + export const ACTIONS = { ADD_TOAST: 'ADD_TOAST', OPEN_TOAST: 'OPEN_TOAST', CLOSE_TOAST: 'CLOSE_TOAST', REMOVE_TOAST: 'REMOVE_TOAST', +} as const; + +export interface ToastEntry { + isOpen: boolean; + children: ReactNode; + // Refcount of active registrants for this key. Incremented on ADD, decremented on REMOVE. + // Prevents two mounted callers sharing a key from stomping each other's registration. + refs: number; } -export const initialState = { - toasts: {} +export interface ToastState { + toasts: Record; } -export function reducer(state, { type, payload }) { - const { key, children } = payload; +export const initialState: ToastState = { + toasts: {}, +}; - switch (type) { +export type ToastAction = + | { type: typeof ACTIONS.ADD_TOAST; payload: { key: string; children: ReactNode } } + | { type: typeof ACTIONS.OPEN_TOAST; payload: { key: string } } + | { type: typeof ACTIONS.CLOSE_TOAST; payload: { key: string } } + | { type: typeof ACTIONS.REMOVE_TOAST; payload: { key: string } }; + +export function reducer(state: ToastState, action: ToastAction): ToastState { + switch (action.type) { case ACTIONS.ADD_TOAST: { + const { key, children } = action.payload; + const existing = state.toasts[key]; return { ...state, toasts: { ...state.toasts, - [key]: { - isOpen: false, - children, - }, + [key]: existing + ? { ...existing, refs: existing.refs + 1 } + : { isOpen: false, children, refs: 1 }, }, }; } case ACTIONS.OPEN_TOAST: { + const { key } = action.payload; + const existing = state.toasts[key]; + if (!existing) { + return state; + } return { ...state, - toasts: { - ...state.toasts, - [key]: { - ...state.toasts[key], - isOpen: true, - }, - }, + toasts: { ...state.toasts, [key]: { ...existing, isOpen: true } }, }; } case ACTIONS.CLOSE_TOAST: { + const { key } = action.payload; + const existing = state.toasts[key]; + if (!existing) { + return state; + } return { ...state, - toasts: { - ...state.toasts, - [key]: { - ...state.toasts[key], - isOpen: false, - }, - }, + toasts: { ...state.toasts, [key]: { ...existing, isOpen: false } }, }; } case ACTIONS.REMOVE_TOAST: { - const newState = { ...state }; - delete newState.toasts[key]; - - return newState; + const { key } = action.payload; + const existing = state.toasts[key]; + if (!existing) { + return state; + } + if (existing.refs > 1) { + return { + ...state, + toasts: { ...state.toasts, [key]: { ...existing, refs: existing.refs - 1 } }, + }; + } + const nextToasts = { ...state.toasts }; + delete nextToasts[key]; + return { ...state, toasts: nextToasts }; } default: - throw Error('Please pick an available action') + return state; } } diff --git a/webclient/src/components/Token/Token.tsx b/webclient/src/components/Token/Token.tsx index 9a9b4768d..5daa4e2a9 100644 --- a/webclient/src/components/Token/Token.tsx +++ b/webclient/src/components/Token/Token.tsx @@ -1,6 +1,3 @@ -// eslint-disable-next-line -import React, { useMemo, useState } from 'react'; - import { TokenDTO } from '@app/services'; import './Token.css'; @@ -10,10 +7,11 @@ interface TokenProps { } const Token = ({ token }: TokenProps) => { - const set = Array.isArray(token?.set) ? token?.set[0] : token?.set; - return token && ( - {token?.name?.value} - ); -} + if (!token) { + return null; + } + const set = Array.isArray(token.set) ? token.set[0] : token.set; + return {token.name?.value}; +}; export default Token; diff --git a/webclient/src/components/TokenDetails/TokenDetails.tsx b/webclient/src/components/TokenDetails/TokenDetails.tsx index 9166a554f..460173627 100644 --- a/webclient/src/components/TokenDetails/TokenDetails.tsx +++ b/webclient/src/components/TokenDetails/TokenDetails.tsx @@ -1,6 +1,3 @@ -// eslint-disable-next-line -import React, { useMemo, useState } from 'react'; - import { TokenDTO } from '@app/services'; import Token from '../Token/Token'; @@ -21,7 +18,7 @@ const TokenDetails = ({ token }: TokenProps) => {
{ - token && ( + token && props && (
@@ -29,52 +26,42 @@ const TokenDetails = ({ token }: TokenProps) => { {token.name?.value}
- { - (!props.pt?.value) ? null : ( -
- P/T: - {props.pt.value} -
- ) - } + {props.pt?.value && ( +
+ P/T: + {props.pt.value} +
+ )} - { - !props.colors?.value ? null : ( -
- Color(s): - {props.colors.value} -
- ) - } + {props.colors?.value && ( +
+ Color(s): + {props.colors.value} +
+ )} - { - !props.maintype?.value ? null : ( -
- Main Type: - {props.maintype.value} -
- ) - } + {props.maintype?.value && ( +
+ Main Type: + {props.maintype.value} +
+ )} - { - !props.type?.value ? null : ( -
- Type: - {props.type.value} -
- ) - } + {props.type?.value && ( +
+ Type: + {props.type.value} +
+ )}
- { - !token.text?.value ? null : ( -
-
- {token.text.value} -
+ {token.text?.value && ( +
+
+ {token.text.value}
- ) - } +
+ )}
) } diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index a58f89c1d..994ae8d99 100644 --- a/webclient/src/components/UserDisplay/UserDisplay.tsx +++ b/webclient/src/components/UserDisplay/UserDisplay.tsx @@ -32,7 +32,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
- {country} + {country}
{name}
diff --git a/webclient/src/components/VirtualList/VirtualList.tsx b/webclient/src/components/VirtualList/VirtualList.tsx index 90c15436e..da9bf8e64 100644 --- a/webclient/src/components/VirtualList/VirtualList.tsx +++ b/webclient/src/components/VirtualList/VirtualList.tsx @@ -1,12 +1,16 @@ -// eslint-disable-next-line -import React from "react"; - +import { ReactNode } from 'react'; import { List, RowComponentProps } from 'react-window'; import './VirtualList.css'; interface RowData { - items: any[]; + items: ReactNode[]; +} + +interface VirtualListProps { + items: ReactNode[]; + className?: string; + size?: number; } const Row = ({ index, style, items }: RowComponentProps) => ( @@ -15,7 +19,7 @@ const Row = ({ index, style, items }: RowComponentProps) => (
); -const VirtualList = ({ items, className = '', size = 30 }) => ( +const VirtualList = ({ items, className = '', size = 30 }: VirtualListProps) => (
className={`virtual-list__list ${className}`} diff --git a/webclient/src/components/fieldTypes.ts b/webclient/src/components/fieldTypes.ts new file mode 100644 index 000000000..43af60a13 --- /dev/null +++ b/webclient/src/components/fieldTypes.ts @@ -0,0 +1,3 @@ +import type { FieldRenderProps } from 'react-final-form'; + +export type FinalFormFieldProps = FieldRenderProps; diff --git a/webclient/src/components/index.ts b/webclient/src/components/index.ts index 219470378..e8a52ca6e 100644 --- a/webclient/src/components/index.ts +++ b/webclient/src/components/index.ts @@ -1,3 +1,5 @@ +export type { FinalFormFieldProps } from './fieldTypes'; + // Common components export { default as Card } from './Card/Card'; export { default as CardDetails } from './CardDetails/CardDetails'; diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index e8b23cc8b..556ff442d 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -7,8 +7,7 @@ import Paper from '@mui/material/Paper'; import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components'; import Layout from '../Layout/Layout'; -import AddToBuddies from './AddToBuddies'; -import AddToIgnore from './AddToIgnore'; +import AddUserForm from './AddUserForm'; import { useAccount } from './useAccount'; import './Account.css'; @@ -44,7 +43,7 @@ const Account = () => { ))} />
- +
@@ -61,7 +60,7 @@ const Account = () => { ))} />
- +
diff --git a/webclient/src/containers/Account/AddToBuddies.tsx b/webclient/src/containers/Account/AddToBuddies.tsx deleted file mode 100644 index 7fb05859d..000000000 --- a/webclient/src/containers/Account/AddToBuddies.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Form } from 'react-final-form' - -import { InputAction } from '@app/components'; - -const AddToBuddies = ({ onSubmit }) => ( -
onSubmit(values)}> - {({ handleSubmit }) => ( - - - - )} - -); - -export default AddToBuddies; diff --git a/webclient/src/containers/Account/AddToIgnore.tsx b/webclient/src/containers/Account/AddToIgnore.tsx deleted file mode 100644 index 5149de0f5..000000000 --- a/webclient/src/containers/Account/AddToIgnore.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Form } from 'react-final-form' - -import { InputAction } from '@app/components'; - -const AddToIgnore = ({ onSubmit }) => ( -
onSubmit(values)}> - {({ handleSubmit }) => ( - - - - )} - -); - -export default AddToIgnore; diff --git a/webclient/src/containers/Account/AddUserForm.tsx b/webclient/src/containers/Account/AddUserForm.tsx new file mode 100644 index 000000000..4c75597a5 --- /dev/null +++ b/webclient/src/containers/Account/AddUserForm.tsx @@ -0,0 +1,24 @@ +import { Form } from 'react-final-form'; + +import { InputAction } from '@app/components'; + +interface AddUserFormValues { + userName: string; +} + +interface AddUserFormProps { + label: string; + onSubmit: (values: AddUserFormValues) => void; +} + +const AddUserForm = ({ label, onSubmit }: AddUserFormProps) => ( + onSubmit={(values) => onSubmit(values)}> + {({ handleSubmit }) => ( +
+ + + )} + +); + +export default AddUserForm; diff --git a/webclient/src/containers/Account/useAccount.ts b/webclient/src/containers/Account/useAccount.ts index 8a0ae6a90..e67f8acd4 100644 --- a/webclient/src/containers/Account/useAccount.ts +++ b/webclient/src/containers/Account/useAccount.ts @@ -2,13 +2,14 @@ import { useEffect, useMemo } from 'react'; import { useWebClient } from '@app/hooks'; import { ServerSelectors, useAppSelector } from '@app/store'; +import { Data } from '@app/types'; export interface Account { - buddyList: any[]; - ignoreList: any[]; + buddyList: Data.ServerInfo_User[]; + ignoreList: Data.ServerInfo_User[]; serverName: string | undefined; serverVersion: string | undefined; - user: any; + user: Data.ServerInfo_User | null; avatarUrl: string; handleAddToBuddies: (args: { userName: string }) => void; handleAddToIgnore: (args: { userName: string }) => void; @@ -22,13 +23,13 @@ export function useAccount(): Account { const serverVersion = useAppSelector((state) => ServerSelectors.getVersion(state)); const user = useAppSelector((state) => ServerSelectors.getUser(state)); const webClient = useWebClient(); - const { avatarBmp } = user || {}; + const avatarBmp = user?.avatarBmp; const avatarUrl = useMemo(() => { if (!avatarBmp) { return ''; } - return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' })); + return URL.createObjectURL(new Blob([avatarBmp], { type: 'image/png' })); }, [avatarBmp]); useEffect(() => { diff --git a/webclient/src/containers/App/AppShell.tsx b/webclient/src/containers/App/AppShell.tsx index 4a0856bc5..dedb5c63b 100644 --- a/webclient/src/containers/App/AppShell.tsx +++ b/webclient/src/containers/App/AppShell.tsx @@ -8,23 +8,22 @@ import FeatureDetection from './FeatureDetection'; import './AppShell.css'; -import { ToastProvider } from '@app/components' +import { ToastProvider } from '@app/components'; function AppShell() { useEffect(() => { window.onbeforeunload = () => true; + return () => { + window.onbeforeunload = null; + }; }, []); - const handleContextMenu = (event) => { - event.preventDefault(); - }; - return ( -
+
diff --git a/webclient/src/containers/App/AppShellRoutes.tsx b/webclient/src/containers/App/AppShellRoutes.tsx index fc097ffd4..2f4fb2f76 100644 --- a/webclient/src/containers/App/AppShellRoutes.tsx +++ b/webclient/src/containers/App/AppShellRoutes.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { App } from '@app/types'; diff --git a/webclient/src/containers/Decks/Decks.tsx b/webclient/src/containers/Decks/Decks.tsx index e5856eaeb..5050a2535 100644 --- a/webclient/src/containers/Decks/Decks.tsx +++ b/webclient/src/containers/Decks/Decks.tsx @@ -7,7 +7,7 @@ function Decks() { return ( - "Decks" + Decks ); } diff --git a/webclient/src/containers/Layout/Layout.tsx b/webclient/src/containers/Layout/Layout.tsx index 858011b42..6bdfdc150 100644 --- a/webclient/src/containers/Layout/Layout.tsx +++ b/webclient/src/containers/Layout/Layout.tsx @@ -1,12 +1,21 @@ +import { ReactNode } from 'react'; + import LeftNav from './LeftNav'; import './Layout.css' -function Layout(props:LayoutProps) { +interface LayoutProps { + showNav?: boolean; + children: ReactNode; + className?: string; + noHeightLimit?: boolean; +} + +function Layout(props: LayoutProps) { const { children, className, showNav = true, noHeightLimit = false } = props; - const containerClasses = ['layout'] - if (noHeightLimit === true) { - containerClasses.push('layout--no-height-limit') + const containerClasses = ['layout']; + if (noHeightLimit) { + containerClasses.push('layout--no-height-limit'); } return ( @@ -29,11 +38,4 @@ function BottomBar() { ) } -interface LayoutProps { - showNav?: boolean; - children: any; - className?: string; - noHeightLimit?: boolean -} - export default Layout; diff --git a/webclient/src/containers/Layout/LeftNav.css b/webclient/src/containers/Layout/LeftNav.css index 85c851d2e..d3936ffd4 100644 --- a/webclient/src/containers/Layout/LeftNav.css +++ b/webclient/src/containers/Layout/LeftNav.css @@ -38,9 +38,6 @@ margin-left: 10px; } -.LeftNav-nav { -} - .LeftNav-nav__links { display: flex; flex-flow: column; @@ -102,27 +99,7 @@ justify-content: center; } -.LeftNav-nav__action { - -} - .LeftNav-nav__action button { color: white; } -.temp-subnav__rooms { - display: flex; - align-items: center; - font-size: 10px; - padding: 5px; -} - -.temp-chip { - margin-left: 5px; - text-decoration: none; -} - - -.temp-chip > div { - cursor: inherit; -} diff --git a/webclient/src/containers/Layout/LeftNav.tsx b/webclient/src/containers/Layout/LeftNav.tsx index ffcb3a7fd..d3d671cb4 100644 --- a/webclient/src/containers/Layout/LeftNav.tsx +++ b/webclient/src/containers/Layout/LeftNav.tsx @@ -109,8 +109,8 @@ const LeftNav = () => { }} > {state.options.map((option) => ( - handleMenuItemClick(option)}> - {option} + handleMenuItemClick(option)}> + {option.label} ))} diff --git a/webclient/src/containers/Layout/useLeftNav.ts b/webclient/src/containers/Layout/useLeftNav.ts index 3b3a0c4ce..66de06c56 100644 --- a/webclient/src/containers/Layout/useLeftNav.ts +++ b/webclient/src/containers/Layout/useLeftNav.ts @@ -1,68 +1,69 @@ -import { useEffect, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate, generatePath } from 'react-router-dom'; import { useWebClient } from '@app/hooks'; import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store'; import { App } from '@app/types'; +export interface LeftNavOption { + label: string; + route: App.RouteEnum; +} + interface LeftNavState { anchorEl: Element | null; showCardImportDialog: boolean; - options: string[]; + options: LeftNavOption[]; } export interface LeftNav { - joinedRooms: any[]; + joinedRooms: ReturnType; isConnected: boolean; state: LeftNavState; handleMenuOpen: (event: React.MouseEvent) => void; - handleMenuItemClick: (option: string) => void; + handleMenuItemClick: (option: LeftNavOption) => void; handleMenuClose: () => void; leaveRoom: (event: React.MouseEvent, roomId: number) => void; openImportCardWizard: () => void; closeImportCardWizard: () => void; } +const BASE_OPTIONS: LeftNavOption[] = [ + { label: 'Account', route: App.RouteEnum.ACCOUNT }, + { label: 'Replays', route: App.RouteEnum.REPLAYS }, +]; + +const MODERATOR_OPTIONS: LeftNavOption[] = [ + { label: 'Administration', route: App.RouteEnum.ADMINISTRATION }, + { label: 'Logs', route: App.RouteEnum.LOGS }, +]; + export function useLeftNav(): LeftNav { const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state)); const isConnected = useAppSelector(ServerSelectors.getIsConnected); const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); const navigate = useNavigate(); const webClient = useWebClient(); - const [state, setState] = useState({ - anchorEl: null, - showCardImportDialog: false, - options: [], - }); + const [anchorEl, setAnchorEl] = useState(null); + const [showCardImportDialog, setShowCardImportDialog] = useState(false); - useEffect(() => { - let options: string[] = [ - 'Account', - 'Replays', - ]; + const options = useMemo( + () => (isModerator ? [...BASE_OPTIONS, ...MODERATOR_OPTIONS] : BASE_OPTIONS), + [isModerator], + ); - if (isModerator) { - options = [ - ...options, - 'Administration', - 'Logs', - ]; - } - - setState((s) => ({ ...s, options })); - }, [isModerator]); + const state: LeftNavState = { anchorEl, showCardImportDialog, options }; const handleMenuOpen = (event: React.MouseEvent) => { - setState((s) => ({ ...s, anchorEl: event.target as Element })); + setAnchorEl(event.target as Element); }; - const handleMenuItemClick = (option: string) => { - const route = App.RouteEnum[option.toUpperCase()]; - navigate(generatePath(route)); + const handleMenuItemClick = (option: LeftNavOption) => { + navigate(generatePath(option.route)); }; const handleMenuClose = () => { - setState((s) => ({ ...s, anchorEl: null })); + setAnchorEl(null); }; const leaveRoom = (event: React.MouseEvent, roomId: number) => { @@ -71,12 +72,12 @@ export function useLeftNav(): LeftNav { }; const openImportCardWizard = () => { - setState((s) => ({ ...s, showCardImportDialog: true })); + setShowCardImportDialog(true); handleMenuClose(); }; const closeImportCardWizard = () => { - setState((s) => ({ ...s, showCardImportDialog: false })); + setShowCardImportDialog(false); }; return { diff --git a/webclient/src/containers/Login/Login.css b/webclient/src/containers/Login/Login.css index 7166187ad..f79b0d008 100644 --- a/webclient/src/containers/Login/Login.css +++ b/webclient/src/containers/Login/Login.css @@ -54,7 +54,7 @@ overflow: hidden; } -.login-content__description-wrapper { +.login-content__description-wrapper { position: relative; width: 70%; display: flex; @@ -115,8 +115,8 @@ margin: 40px 0 20px; font-size: 28px; font-weight: bold; - } + .login-content__description-subtitle2 { font-size: 14px; } diff --git a/webclient/src/containers/Login/useLogin.ts b/webclient/src/containers/Login/useLogin.ts index 4955010d2..4baded8ad 100644 --- a/webclient/src/containers/Login/useLogin.ts +++ b/webclient/src/containers/Login/useLogin.ts @@ -2,6 +2,12 @@ import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useToast } from '@app/components'; +import type { + LoginFormValues, + RegisterFormValues, + RequestPasswordResetFormValues, + ResetPasswordFormValues, +} from '@app/forms'; import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { getHostPort } from '@app/services'; import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store'; @@ -17,16 +23,15 @@ export interface LoginDialogState { export interface Login { description: string | undefined; isConnected: boolean; - pendingActivationOptions: WebsocketTypes.PendingActivationContext | null; dialogState: LoginDialogState; userToResetPassword: string | null; submitButtonDisabled: boolean; - handleLogin: (form: any) => void; + handleLogin: (form: LoginFormValues) => void; showDescription: () => boolean; - handleRegistrationDialogSubmit: (form: any) => void; + handleRegistrationDialogSubmit: (form: RegisterFormValues) => void; handleAccountActivationDialogSubmit: (args: { token: string }) => void; - handleRequestPasswordResetDialogSubmit: (form: any) => void; - handleResetPasswordDialogSubmit: (args: any) => void; + handleRequestPasswordResetDialogSubmit: (form: RequestPasswordResetFormValues) => void; + handleResetPasswordDialogSubmit: (form: ResetPasswordFormValues) => void; skipTokenRequest: (userName: string) => void; closeRequestPasswordResetDialog: () => void; openRequestPasswordResetDialog: () => void; @@ -46,7 +51,7 @@ export function useLogin(): Login { const [pendingActivationOptions, setPendingActivationOptions] = useState(null); - const rememberLoginRef = useRef(null); + const rememberLoginRef = useRef(null); const knownHosts = useKnownHosts(); const [dialogState, setDialogState] = useState({ passwordResetRequestDialog: false, @@ -113,13 +118,13 @@ export function useLogin(): Login { setPendingActivationOptions(null); }, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []); - useReduxEffect(({ payload: { options } }) => { + useReduxEffect<{ options: WebsocketTypes.PendingActivationContext }>(({ payload: { options } }) => { setPendingActivationOptions(options); closeRegistrationDialog(); openActivateAccountDialog(); }, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []); - const onSubmitLogin = useCallback((loginForm) => { + const onSubmitLogin = useCallback((loginForm: LoginFormValues) => { rememberLoginRef.current = loginForm; const { userName, password, selectedHost, remember } = loginForm; @@ -134,7 +139,7 @@ export function useLogin(): Login { } webClient.request.authentication.login(options); - }, []); + }, [webClient]); const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); @@ -142,7 +147,13 @@ export function useLogin(): Login { resetSubmitButton(); }, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []); - const updateHost = (hashedPassword: string, { selectedHost, remember, userName }: any) => { + const updateHost = ( + hashedPassword: string, + { selectedHost, remember, userName }: LoginFormValues, + ) => { + if (selectedHost.id == null) { + return; + } knownHosts.update(selectedHost.id, { remember, userName: remember ? userName : null, @@ -150,9 +161,10 @@ export function useLogin(): Login { }); }; - useReduxEffect(({ payload: { options: { hashedPassword } } }) => { - if (rememberLoginRef.current) { - updateHost(hashedPassword, rememberLoginRef.current); + useReduxEffect<{ options: WebsocketTypes.LoginSuccessContext }>(({ payload: { options } }) => { + const loginForm = rememberLoginRef.current; + if (loginForm && 'remember' in loginForm) { + updateHost(options.hashedPassword, loginForm); } }, ServerTypes.LOGIN_SUCCESSFUL, []); @@ -162,7 +174,7 @@ export function useLogin(): Login { return Boolean(!isConnected && description?.length); }; - const handleRegistrationDialogSubmit = (registerForm: any) => { + const handleRegistrationDialogSubmit = (registerForm: RegisterFormValues) => { rememberLoginRef.current = registerForm; const { userName, password, email, country, realName, selectedHost } = registerForm; @@ -188,7 +200,7 @@ export function useLogin(): Login { }); }; - const handleRequestPasswordResetDialogSubmit = (form: any) => { + const handleRequestPasswordResetDialogSubmit = (form: RequestPasswordResetFormValues) => { const { userName, email, selectedHost } = form; const { host, port } = getHostPort(selectedHost); @@ -200,7 +212,12 @@ export function useLogin(): Login { } }; - const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }: any) => { + const handleResetPasswordDialogSubmit = ({ + userName, + token, + newPassword, + selectedHost, + }: ResetPasswordFormValues) => { const { host, port } = getHostPort(selectedHost); webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port }); }; @@ -218,7 +235,6 @@ export function useLogin(): Login { return { description, isConnected, - pendingActivationOptions, dialogState, userToResetPassword, submitButtonDisabled, diff --git a/webclient/src/containers/Logs/LogResults.tsx b/webclient/src/containers/Logs/LogResults.tsx index 02cf3511a..5f99479cb 100644 --- a/webclient/src/containers/Logs/LogResults.tsx +++ b/webclient/src/containers/Logs/LogResults.tsx @@ -1,3 +1,6 @@ +import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; @@ -8,37 +11,99 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; -import Typography from '@mui/material/Typography'; + +import type { Data } from '@app/types'; +import type { ServerStateLogs } from '@app/store'; import { useLogResults } from './useLogResults'; import './LogResults.css'; -const LogResults = (props) => { - const { logs } = props; +interface LogResultsProps { + logs: ServerStateLogs; +} - const hasRoomLogs = logs.room && logs.room.length; - const hasGameLogs = logs.game && logs.game.length; - const hasChatLogs = logs.chat && logs.chat.length; +interface HeaderCell { + label: string; +} +interface ResultsProps { + headerCells: HeaderCell[]; + logs: Data.ServerInfo_ChatMessage[]; +} + +interface TabPanelProps { + children?: ReactNode; + value: number; + index: number; +} + +const a11yProps = (index: number): { id: string; 'aria-controls': string } => ({ + id: `logs-tab-${index}`, + 'aria-controls': `logs-tabpanel-${index}`, +}); + +const TabPanel = ({ children, value, index }: TabPanelProps) => ( + +); + +const Results = ({ headerCells, logs }: ResultsProps) => ( + + + + + {headerCells.map(({ label }) => ( + {label} + ))} + + + + {logs.map((log, index) => ( + + {log.time} + {log.senderName} + {log.senderIp} + {log.message} + {log.targetId} + {log.targetName} + + ))} + +
+
+); + +const LogResults = ({ logs }: LogResultsProps) => { + const { t } = useTranslation(); const { value, handleChange } = useLogResults(); - const headerCells = [ - { label: 'Time' }, - { label: 'Sender Name' }, - { label: 'Sender IP' }, - { label: 'Message' }, - { label: 'Target ID' }, - { label: 'Target Name' }, + const headerCells: HeaderCell[] = [ + { label: t('Logs.column.time') }, + { label: t('Logs.column.senderName') }, + { label: t('Logs.column.senderIp') }, + { label: t('Logs.column.message') }, + { label: t('Logs.column.targetId') }, + { label: t('Logs.column.targetName') }, ]; + const roomCount = logs.room?.length ?? 0; + const gameCount = logs.game?.length ?? 0; + const chatCount = logs.chat?.length ?? 0; + return (
- - - - + + 0 ? ` [${roomCount}]` : ''}`} {...a11yProps(0)} /> + 0 ? ` [${gameCount}]` : ''}`} {...a11yProps(1)} /> + 0 ? ` [${chatCount}]` : ''}`} {...a11yProps(2)} /> @@ -54,52 +119,4 @@ const LogResults = (props) => { ); }; -const a11yProps = index => { - return { - id: `simple-tab-${index}`, - 'aria-controls': `simple-tabpanel-${index}`, - }; -}; - -const TabPanel = ({ children, value, index, ...other }) => { - return ( - - ); -}; - -const Results = ({ headerCells, logs }) => ( - - - - - {headerCells.map(({ label }) => ( - {label} - ))} - - - - {logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => ( - - {time} - {senderName} - {senderIp} - {message} - {targetId} - {targetName} - - ))} - -
-
-); - export default LogResults; diff --git a/webclient/src/containers/Logs/Logs.i18n.json b/webclient/src/containers/Logs/Logs.i18n.json new file mode 100644 index 000000000..6ffaf1b6f --- /dev/null +++ b/webclient/src/containers/Logs/Logs.i18n.json @@ -0,0 +1,20 @@ +{ + "Logs": { + "tab": { + "rooms": "Rooms", + "games": "Games", + "chats": "Chats" + }, + "column": { + "time": "Time", + "senderName": "Sender Name", + "senderIp": "Sender IP", + "message": "Message", + "targetId": "Target ID", + "targetName": "Target Name" + }, + "message": { + "emptyFilter": "Enter at least one search field before submitting." + } + } +} diff --git a/webclient/src/containers/Logs/useLogs.ts b/webclient/src/containers/Logs/useLogs.ts index 7e2a1057b..57d5c5c59 100644 --- a/webclient/src/containers/Logs/useLogs.ts +++ b/webclient/src/containers/Logs/useLogs.ts @@ -1,19 +1,26 @@ import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useToast } from '@app/components'; import { useWebClient } from '@app/hooks'; -import { ServerDispatch, ServerSelectors, useAppSelector } from '@app/store'; +import { ServerDispatch, ServerSelectors, ServerStateLogs, useAppSelector } from '@app/store'; import { Data } from '@app/types'; const MAXIMUM_RESULTS = 1000; export interface Logs { - logs: any; + logs: ServerStateLogs; onSubmit: (fields: Data.ViewLogHistoryParams) => void; } export function useLogs(): Logs { + const { t } = useTranslation(); const logs = useAppSelector((state) => ServerSelectors.getLogs(state)); const webClient = useWebClient(); + const { openToast } = useToast({ + key: 'logs-empty-filter', + children: t('Logs.message.emptyFilter'), + }); useEffect(() => { return () => { @@ -21,26 +28,29 @@ export function useLogs(): Logs { }; }, []); - const trimFields = (fields: any) => { - const result: any = {}; - for (const [key, field] of Object.entries(fields)) { + const trimFields = (fields: Data.ViewLogHistoryParams): Data.ViewLogHistoryParams => { + const result: Data.ViewLogHistoryParams = { ...fields }; + for (const key of Object.keys(result) as (keyof Data.ViewLogHistoryParams)[]) { + const field = result[key]; if (typeof field === 'string') { const trimmed = field.trim(); if (trimmed) { - result[key] = trimmed; + (result as Record)[key] = trimmed; + } else { + delete (result as Record)[key]; } - } else { - result[key] = field; } } return result; }; - const flattenLogLocations = (logLocations: any) => Object.keys(logLocations); + const flattenLogLocations = (logLocations: Record): string[] => + Object.keys(logLocations); const onSubmit = (fields: Data.ViewLogHistoryParams) => { - const trimmedFields: any = trimFields(fields); - const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields; + const trimmedFields = trimFields(fields); + const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields as + Data.ViewLogHistoryParams & { logLocation?: Record }; const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean); @@ -53,7 +63,7 @@ export function useLogs(): Logs { if (required.length) { webClient.request.moderator.viewLogHistory(trimmedFields); } else { - // @TODO use yet-to-be-implemented banner/alert + openToast(); } }; diff --git a/webclient/src/containers/Player/Player.css b/webclient/src/containers/Player/Player.css new file mode 100644 index 000000000..11d841ca1 --- /dev/null +++ b/webclient/src/containers/Player/Player.css @@ -0,0 +1,66 @@ +.player-view { + display: flex; + justify-content: center; + padding: 24px; +} + +.player-view__card { + width: 100%; + max-width: 640px; + padding: 24px; +} + +.player-view__avatar-wrapper { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.player-view__avatar { + width: 160px; + height: 160px; + border-radius: 8px; + object-fit: cover; + background-color: var(--bg-subtle, #eee); +} + +.player-view__name { + text-align: center; + margin: 0 0 8px; +} + +.player-view__level-badge { + text-align: center; + margin-bottom: 16px; + color: var(--text-muted, #666); +} + +.player-view__details { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 16px; + margin-bottom: 16px; +} + +.player-view__label { + font-weight: bold; +} + +.player-view__country-flag { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + height: 16px; +} + +.player-view__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.player-view__empty { + text-align: center; + color: var(--text-muted, #666); +} diff --git a/webclient/src/containers/Player/Player.i18n.json b/webclient/src/containers/Player/Player.i18n.json new file mode 100644 index 000000000..ac33abfe1 --- /dev/null +++ b/webclient/src/containers/Player/Player.i18n.json @@ -0,0 +1,33 @@ +{ + "Player": { + "title": "User Information", + "label": { + "realName": "Real Name", + "location": "Location", + "userLevel": "User Level", + "accountAge": "Account Age" + }, + "level": { + "administrator": "Administrator", + "moderator": "Moderator", + "registered": "Registered user", + "unregistered": "Unregistered user", + "judge": "Judge" + }, + "age": { + "unknown": "Unknown", + "days": "{count, plural, one {# day} other {# days}}", + "daysWithYears": "{years, plural, one {# year} other {# years}}, {days, plural, one {# day} other {# days}}" + }, + "action": { + "addBuddy": "Add to Buddy List", + "removeBuddy": "Remove from Buddy List", + "addIgnore": "Add to Ignore List", + "removeIgnore": "Remove from Ignore List", + "message": "Message", + "warn": "Warn User", + "ban": "Ban from Server", + "notFound": "User not found or still loading…" + } + } +} diff --git a/webclient/src/containers/Player/Player.tsx b/webclient/src/containers/Player/Player.tsx index 899285054..4bd23685d 100644 --- a/webclient/src/containers/Player/Player.tsx +++ b/webclient/src/containers/Player/Player.tsx @@ -1,14 +1,177 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; + import Layout from '../Layout/Layout'; - import { AuthGuard } from '@app/components'; +import { Images } from '@app/images'; +import { Data } from '@app/types'; + +import { usePlayer } from './usePlayer'; + +import './Player.css'; + +const AVATAR_DATA_URI_PREFIX = 'data:image/png;base64,'; + +/** Builds a data URI from a raw avatar payload, or returns null when no avatar is set. */ +function avatarSrc(bmp: Uint8Array | undefined): string | null { + if (!bmp || bmp.byteLength === 0) { + return null; + } + let binary = ''; + for (let i = 0; i < bmp.byteLength; i += 1) { + binary += String.fromCharCode(bmp[i]); + } + return AVATAR_DATA_URI_PREFIX + btoa(binary); +} + +/** Matches desktop UserInfoBox level hierarchy: admin > moderator > registered > unregistered, plus Judge marker. */ +function userLevelLabel(userLevel: number, t: (k: string) => string): string { + const Flag = Data.ServerInfo_User_UserLevelFlag; + const parts: string[] = []; + if ((userLevel & Flag.IsAdmin) === Flag.IsAdmin) { + parts.push(t('Player.level.administrator')); + } else if ((userLevel & Flag.IsModerator) === Flag.IsModerator) { + parts.push(t('Player.level.moderator')); + } else if ((userLevel & Flag.IsRegistered) === Flag.IsRegistered) { + parts.push(t('Player.level.registered')); + } else { + parts.push(t('Player.level.unregistered')); + } + if ((userLevel & Flag.IsJudge) === Flag.IsJudge) { + parts.push(t('Player.level.judge')); + } + return parts.join(' | '); +} + +/** Formats account age like desktop's getAgeString: "Unknown" | "N days" | "Y years, D days". */ +function formatAccountAge( + accountageSecs: bigint | undefined, + userLevel: number, + t: (k: string, params?: Record) => string, +): string { + const Flag = Data.ServerInfo_User_UserLevelFlag; + const isRegistered = + (userLevel & Flag.IsAdmin) === Flag.IsAdmin || + (userLevel & Flag.IsModerator) === Flag.IsModerator || + (userLevel & Flag.IsRegistered) === Flag.IsRegistered; + if (!isRegistered) { + return t('Player.level.unregistered'); + } + if (!accountageSecs || accountageSecs <= 0n) { + return t('Player.age.unknown'); + } + const totalDays = Number(accountageSecs / 86400n); + const years = Math.floor(totalDays / 365); + const days = totalDays - years * 365; + if (years > 0) { + return t('Player.age.daysWithYears', { years, days }); + } + return t('Player.age.days', { count: days }); +} + +const Player = () => { + const { t } = useTranslation(); + const { + name, + userInfo, + isSelf, + isABuddy, + isIgnored, + isModerator, + onAddBuddy, + onRemoveBuddy, + onAddIgnore, + onRemoveIgnore, + onSendMessage, + onWarnUser, + onBanFromServer, + } = usePlayer(); + + const avatar = useMemo(() => avatarSrc(userInfo?.avatarBmp), [userInfo?.avatarBmp]); + const countryCode = userInfo?.country?.toUpperCase() ?? ''; -function Player() { return ( - "Player" +
+ + + {t('Player.title')} + + + {!userInfo && ( + {t('Player.action.notFound')} + )} + + {userInfo && ( + <> +
+ {avatar + ? {name + : + + {userInfo.name} + + {userLevelLabel(userInfo.userLevel, t)} + {userInfo.privlevel && userInfo.privlevel !== 'NONE' ? ` | ${userInfo.privlevel}` : ''} + + +
+ {t('Player.label.realName')} + {userInfo.realName || '—'} + + {t('Player.label.location')} + + {countryCode && ( + {countryCode} + )} + {countryCode || '—'} + + + {t('Player.label.userLevel')} + {userLevelLabel(userInfo.userLevel, t)} + + {t('Player.label.accountAge')} + {formatAccountAge(userInfo.accountageSecs, userInfo.userLevel, t)} +
+ + {!isSelf && ( +
+ + + + {isModerator && ( + <> + + + + )} +
+ )} + + )} + +
); -} +}; export default Player; diff --git a/webclient/src/containers/Player/usePlayer.ts b/webclient/src/containers/Player/usePlayer.ts new file mode 100644 index 000000000..6a74467b8 --- /dev/null +++ b/webclient/src/containers/Player/usePlayer.ts @@ -0,0 +1,84 @@ +import { useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useWebClient } from '@app/hooks'; +import { ServerSelectors, useAppSelector } from '@app/store'; +import type { Data } from '@app/types'; + +export interface PlayerViewModel { + /** Resolved username from the route; null when the `:name` param is missing. */ + name: string | null; + /** Profile record from server.userInfo[name]; undefined until the server response lands. */ + userInfo: Data.ServerInfo_User | undefined; + /** The logged-in user, for self-profile and mod-permission checks. */ + currentUser: Data.ServerInfo_User | null; + isSelf: boolean; + isABuddy: boolean; + isIgnored: boolean; + isModerator: boolean; + + onAddBuddy: () => void; + onRemoveBuddy: () => void; + onAddIgnore: () => void; + onRemoveIgnore: () => void; + onSendMessage: (message: string) => void; + onWarnUser: (reason: string) => void; + onBanFromServer: (minutes: number, reason: string, visibleReason?: string) => void; +} + +/** + * Drives the Player container: resolves the `:name` route param, dispatches + * `getUserInfo` on mount so the server populates `server.userInfo[name]`, and + * exposes the buddy/ignore/mod-action callbacks desktop surfaces in UserInfoBox. + */ +export function usePlayer(): PlayerViewModel { + const webClient = useWebClient(); + const params = useParams<{ name?: string }>(); + const name = params.name ?? null; + + const userInfo = useAppSelector((state) => + name ? ServerSelectors.getUserInfoByName(state, name) : undefined, + ); + const currentUser = useAppSelector(ServerSelectors.getUser); + const buddyList = useAppSelector(ServerSelectors.getBuddyList); + const ignoreList = useAppSelector(ServerSelectors.getIgnoreList); + const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); + + useEffect(() => { + if (name) { + webClient.request.session.getUserInfo(name); + } + }, [name, webClient]); + + const { isSelf, isABuddy, isIgnored } = useMemo(() => ({ + isSelf: Boolean(currentUser && name && currentUser.name === name), + isABuddy: Boolean(name && buddyList[name]), + isIgnored: Boolean(name && ignoreList[name]), + }), [currentUser, name, buddyList, ignoreList]); + + const onAddBuddy = () => name && webClient.request.session.addToBuddyList(name); + const onRemoveBuddy = () => name && webClient.request.session.removeFromBuddyList(name); + const onAddIgnore = () => name && webClient.request.session.addToIgnoreList(name); + const onRemoveIgnore = () => name && webClient.request.session.removeFromIgnoreList(name); + const onSendMessage = (message: string) => name && webClient.request.session.sendDirectMessage(name, message); + const onWarnUser = (reason: string) => name && webClient.request.moderator.warnUser(name, reason); + const onBanFromServer = (minutes: number, reason: string, visibleReason?: string) => + name && webClient.request.moderator.banFromServer(minutes, name, undefined, reason, visibleReason); + + return { + name, + userInfo, + currentUser, + isSelf, + isABuddy, + isIgnored, + isModerator, + onAddBuddy, + onRemoveBuddy, + onAddIgnore, + onRemoveIgnore, + onSendMessage, + onWarnUser, + onBanFromServer, + }; +} diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.tsx index 39fd26c00..ec920fafc 100644 --- a/webclient/src/containers/Room/GameSelector/GameSelector.tsx +++ b/webclient/src/containers/Room/GameSelector/GameSelector.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; @@ -10,6 +10,7 @@ import { RoomsSelectors, ServerSelectors, useAppSelector, + type GameFilters, } from '@app/store'; import { useReduxEffect, useWebClient } from '@app/hooks'; import { App, type Enriched } from '@app/types'; @@ -115,7 +116,7 @@ const GameSelector = ({ room }: GameSelectorProps) => { setCreateOpen(false); }; - const handleFilterSubmit = (next) => { + const handleFilterSubmit = (next: GameFilters) => { RoomsDispatch.setGameFilters(roomId, next); setFilterOpen(false); }; diff --git a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx index 4bf0791d3..7c844ce15 100644 --- a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx +++ b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Button from '@mui/material/Button'; import FilterListIcon from '@mui/icons-material/FilterList'; import FilterListOffIcon from '@mui/icons-material/FilterListOff'; diff --git a/webclient/src/containers/Room/Games.css b/webclient/src/containers/Room/Games.css deleted file mode 100644 index 623ab47f5..000000000 --- a/webclient/src/containers/Room/Games.css +++ /dev/null @@ -1,30 +0,0 @@ -.games { -} - -.games-header, -.game { - display: flex; - padding: 10px; - border-bottom: 1px solid black; -} - -.games-header__cell { - max-width: 200px; -} - -.games-header__label, -.game__detail { - width: 10%; - flex-grow: 0; -} - -.games-header__label.description, -.game__detail.description { - width: 20%; - flex-grow: 1; -} - -.games-header__label.creator, -.game__detail.creator { - width: 20%; -} diff --git a/webclient/src/containers/Room/Games.tsx b/webclient/src/containers/Room/Games.tsx deleted file mode 100644 index 43777f35c..000000000 --- a/webclient/src/containers/Room/Games.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import TableSortLabel from '@mui/material/TableSortLabel'; -import Tooltip from '@mui/material/Tooltip'; - -import { UserDisplay } from '@app/components'; - -import { useGames } from './useGames'; - -import './Games.css'; - -// @TODO run interval to update timeSinceCreated -interface GamesProps { - room: any; -} - -const Games = ({ room }: GamesProps) => { - const roomId = room.info.roomId; - const { sortBy, games, handleSort } = useGames(roomId); - - const headerCells = [ - { label: 'Age', field: 'info.startTime' }, - { label: 'Description', field: 'info.description' }, - { label: 'Creator', field: 'info.creatorInfo.name' }, - { label: 'Type', field: 'gameType' }, - { label: 'Restrictions' }, - { label: 'Players' }, - { label: 'Spectators', field: 'info.spectatorsCount' }, - ]; - - return ( -
- - - - {headerCells.map(({ label, field }) => { - const active = field === sortBy.field; - const order = sortBy.order.toLowerCase(); - const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false; - - return ( - - {!field ? label : ( - handleSort(field)} - > - {label} - - )} - - ); - })} - - - - {games.map((game) => { - const { info, gameType } = game; - const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info; - return ( - - {startTime} - - -
- {description} -
-
-
- - - - {gameType} - ? - {`${playerCount}/${maxPlayers}`} - {spectatorsCount} -
- ); - })} -
-
-
- ); -}; - -export default Games; diff --git a/webclient/src/containers/Room/Messages.tsx b/webclient/src/containers/Room/Messages.tsx index 88e727b53..764bb6aa7 100644 --- a/webclient/src/containers/Room/Messages.tsx +++ b/webclient/src/containers/Room/Messages.tsx @@ -1,15 +1,17 @@ -// eslint-disable-next-line -import React from "react"; - import { Message } from '@app/components'; +import type { Enriched } from '@app/types'; import './Messages.css'; -const Messages = ({ messages }) => ( +interface MessagesProps { + messages?: Enriched.Message[]; +} + +const Messages = ({ messages }: MessagesProps) => (
{ - messages && messages.map((message) => ( -
+ messages && messages.map((message, idx) => ( +
)) diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx index 916d7823e..c82ae6a78 100644 --- a/webclient/src/containers/Room/OpenGames.tsx +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -14,7 +14,7 @@ import { useOpenGames } from './useOpenGames'; import './OpenGames.css'; interface OpenGamesProps { - room: { info: { roomId: number } }; + room: Enriched.Room; onActivateGame?: (gameId: number) => void; } diff --git a/webclient/src/containers/Room/SayMessage.tsx b/webclient/src/containers/Room/SayMessage.tsx index 589ebdacc..52f416e1b 100644 --- a/webclient/src/containers/Room/SayMessage.tsx +++ b/webclient/src/containers/Room/SayMessage.tsx @@ -1,14 +1,17 @@ -import React from 'react'; -import { Form } from 'react-final-form' +import { Form } from 'react-final-form'; import { InputAction } from '@app/components'; -const SayMessage = ({ onSubmit }) => ( +interface SayMessageProps { + onSubmit: (args: { message: string }) => void; +} + +const SayMessage = ({ onSubmit }: SayMessageProps) => (
{({ handleSubmit, form }) => ( { - handleSubmit(e) - form.restart() + handleSubmit(e); + form.restart(); }}> diff --git a/webclient/src/containers/Room/useGames.ts b/webclient/src/containers/Room/useGames.ts deleted file mode 100644 index b00dba1cb..000000000 --- a/webclient/src/containers/Room/useGames.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SortUtil, RoomsDispatch, RoomsSelectors, useAppSelector } from '@app/store'; -import { App } from '@app/types'; - -export interface Games { - sortBy: { field: string; order: string }; - games: any[]; - handleSort: (sortByField: string) => void; -} - -export function useGames(roomId: number): Games { - const sortBy = useAppSelector((state) => RoomsSelectors.getSortGamesBy(state)); - const sortedGames = useAppSelector((state) => RoomsSelectors.getSortedRoomGames(state, roomId)); - - const handleSort = (sortByField: string) => { - const { field, order } = SortUtil.toggleSortBy(sortByField as App.GameSortField, sortBy); - RoomsDispatch.sortGames(roomId, field, order); - }; - - const isUnavailableGame = ({ started, maxPlayers, playerCount }) => - !started && playerCount < maxPlayers; - - const isPasswordProtectedGame = ({ withPassword }) => !withPassword; - - const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies; - - const games = sortedGames.filter((game) => ( - isUnavailableGame(game.info) && - isPasswordProtectedGame(game.info) && - isBuddiesOnlyGame(game.info) - )); - - return { sortBy, games, handleSort }; -} diff --git a/webclient/src/containers/Room/useRoom.ts b/webclient/src/containers/Room/useRoom.ts index 466f6f8f2..c43a29246 100644 --- a/webclient/src/containers/Room/useRoom.ts +++ b/webclient/src/containers/Room/useRoom.ts @@ -1,15 +1,15 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useNavigate, useParams, generatePath } from 'react-router-dom'; import { useWebClient } from '@app/hooks'; import { RoomsSelectors, useAppSelector } from '@app/store'; -import { App } from '@app/types'; +import { App, Data, Enriched } from '@app/types'; export interface Room { roomId: number; - room: any; - roomMessages: any; - users: any[]; + room: Enriched.Room | undefined; + roomMessages: Enriched.Message[] | undefined; + users: Data.ServerInfo_User[]; handleRoomSay: (args: { message: string }) => void; } @@ -20,23 +20,24 @@ export function useRoom(): Room { const navigate = useNavigate(); const params = useParams(); - const roomId = parseInt(params.roomId, 10); - const room = rooms[roomId]; - const roomMessages = messages[roomId]; + const parsed = params.roomId != null ? parseInt(params.roomId, 10) : NaN; + const roomId = Number.isNaN(parsed) ? -1 : parsed; + const room = roomId === -1 ? undefined : rooms[roomId]; + const roomMessages = roomId === -1 ? undefined : messages[roomId]; const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId)); const webClient = useWebClient(); useEffect(() => { - if (!joined.find((r) => r.info.roomId === roomId)) { + if (roomId === -1 || !joined.find((r) => r.info.roomId === roomId)) { navigate(generatePath(App.RouteEnum.SERVER)); } - }, [joined]); + }, [joined, roomId, navigate]); - const handleRoomSay = ({ message }: { message: string }) => { + const handleRoomSay = useCallback(({ message }: { message: string }) => { if (message) { webClient.request.rooms.roomSay(roomId, message); } - }; + }, [webClient, roomId]); return { roomId, room, roomMessages, users, handleRoomSay }; } diff --git a/webclient/src/containers/Server/Rooms.css b/webclient/src/containers/Server/Rooms.css index bfcdc82cf..0cc990fca 100644 --- a/webclient/src/containers/Server/Rooms.css +++ b/webclient/src/containers/Server/Rooms.css @@ -1,6 +1,3 @@ -.rooms { -} - .rooms-header, .room { display: flex; diff --git a/webclient/src/containers/Server/Rooms.tsx b/webclient/src/containers/Server/Rooms.tsx index 1eecce729..98915599c 100644 --- a/webclient/src/containers/Server/Rooms.tsx +++ b/webclient/src/containers/Server/Rooms.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React from "react"; +import { useMemo } from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; import Button from '@mui/material/Button'; @@ -10,21 +9,31 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import { useWebClient } from '@app/hooks'; -import { App } from '@app/types'; +import { App, Enriched } from '@app/types'; import './Rooms.css'; -const Rooms = ({ rooms, joinedRooms }) => { +interface RoomsProps { + rooms: Record; + joinedRooms: Enriched.Room[]; +} + +const Rooms = ({ rooms, joinedRooms }: RoomsProps) => { const navigate = useNavigate(); const webClient = useWebClient(); - function onClick(roomId) { - if (joinedRooms.find(room => room.info.roomId === roomId)) { - navigate(generatePath(App.RouteEnum.ROOM, { roomId })); + const joinedRoomIds = useMemo( + () => new Set(joinedRooms.map((room) => room.info.roomId)), + [joinedRooms], + ); + + const onClick = (roomId: number) => { + if (joinedRoomIds.has(roomId)) { + navigate(generatePath(App.RouteEnum.ROOM, { roomId: String(roomId) })); } else { webClient.request.rooms.joinRoom(roomId); } - } + }; return (
@@ -40,7 +49,7 @@ const Rooms = ({ rooms, joinedRooms }) => { - { Object.values(rooms).map((room) => { + {Object.values(rooms).map((room) => { const { description, gameCount, name, permissionlevel, playerCount, roomId } = room.info; return ( diff --git a/webclient/src/containers/Server/Server.tsx b/webclient/src/containers/Server/Server.tsx index 1620138fc..3eba3aa83 100644 --- a/webclient/src/containers/Server/Server.tsx +++ b/webclient/src/containers/Server/Server.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { useMemo } from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; import ListItemButton from '@mui/material/ListItemButton'; @@ -6,9 +6,8 @@ import Paper from '@mui/material/Paper'; import { AuthGuard, ThreePaneLayout, UserDisplay, VirtualList } from '@app/components'; import { useReduxEffect } from '@app/hooks'; -import { RoomsSelectors, RoomsTypes, ServerSelectors } from '@app/store'; -import { App } from '@app/types'; -import { useAppSelector } from '@app/store'; +import { RoomsSelectors, RoomsTypes, ServerSelectors, useAppSelector } from '@app/store'; +import { App, Data } from '@app/types'; import Rooms from './Rooms'; import Layout from '../Layout/Layout'; @@ -21,11 +20,20 @@ const Server = () => { const users = useAppSelector(state => ServerSelectors.getSortedUsers(state)); const navigate = useNavigate(); - useReduxEffect((action: any) => { + useReduxEffect<{ roomInfo: Data.ServerInfo_Room }>((action) => { const roomId = action.payload.roomInfo.roomId.toString(); navigate(generatePath(App.RouteEnum.ROOM, { roomId })); }, RoomsTypes.JOIN_ROOM, []); + const userItems = useMemo( + () => users.map((user) => ( + + + + )), + [users], + ); + return ( @@ -49,13 +57,7 @@ const Server = () => {
Users connected to server: {users.length}
- ( - - - - )) } - /> + )} /> diff --git a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css index 5175ab845..afa94e3f9 100644 --- a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css +++ b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css @@ -1,13 +1,3 @@ -.dialog-title { - display: flex; - justify-content: space-between; - align-items: center; -} - -.MuiDialogTitle-root.dialog-title { - padding-bottom: 0; -} - -.content { - margin-bottom: 20px; -} \ No newline at end of file +.content { + margin-bottom: 20px; +} diff --git a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx index 476a399ec..62e49e094 100644 --- a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx +++ b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx @@ -1,43 +1,35 @@ -import React from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; +import type { AccountActivationFormValues } from '@app/forms'; import { AccountActivationForm } from '@app/forms'; +import AuthDialogShell from '../AuthDialogShell/AuthDialogShell'; + import './AccountActivationDialog.css'; -const AccountActivationDialog = ({ handleClose, isOpen, onSubmit }: any) => { +interface AccountActivationDialogProps { + isOpen: boolean; + handleClose?: () => void; + onSubmit: (values: AccountActivationFormValues) => void; +} + +const AccountActivationDialog = ({ handleClose, isOpen, onSubmit }: AccountActivationDialogProps) => { const { t } = useTranslation(); - const handleOnClose = () => { - handleClose(); - } - return ( - - - { t('AccountActivationDialog.title') } + +
+ { t('AccountActivationDialog.subtitle1') } + { t('AccountActivationDialog.subtitle2') } +
- {handleOnClose ? ( - - - - ) : null} -
- -
- { t('AccountActivationDialog.subtitle1') } - { t('AccountActivationDialog.subtitle2') } -
- - -
-
+ + ); }; diff --git a/webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.css b/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.css similarity index 60% rename from webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.css rename to webclient/src/dialogs/AuthDialogShell/AuthDialogShell.css index 731927c13..a70ab9f2b 100644 --- a/webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.css +++ b/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.css @@ -1,5 +1,9 @@ -.dialog-title { - display: flex; - justify-content: space-between; - align-items: center; -} +.dialog-title { + display: flex; + justify-content: space-between; + align-items: center; +} + +.MuiDialogTitle-root.dialog-title { + padding-bottom: 0; +} diff --git a/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx b/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx new file mode 100644 index 000000000..44d2db4da --- /dev/null +++ b/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx @@ -0,0 +1,55 @@ +import { ReactNode } from 'react'; +import Dialog, { DialogProps } from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import CloseIcon from '@mui/icons-material/Close'; + +import './AuthDialogShell.css'; + +export interface AuthDialogShellProps { + isOpen: boolean; + handleClose?: () => void; + title: string; + children: ReactNode; + className?: string; + contentClassName?: string; + maxWidth?: DialogProps['maxWidth']; +} + +const AuthDialogShell = ({ + isOpen, + handleClose, + title, + children, + className, + contentClassName, + maxWidth, +}: AuthDialogShellProps) => { + const closeGuarded = handleClose ? () => handleClose() : undefined; + + return ( + + + {title} + + {closeGuarded ? ( + + + + ) : null} + + + {children} + + + ); +}; + +export default AuthDialogShell; diff --git a/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx b/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx index a5fc1da90..e887f8169 100644 --- a/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx +++ b/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; @@ -10,24 +9,23 @@ import { CardImportForm } from '@app/forms'; import './CardImportDialog.css'; -const CardImportDialog = ({ handleClose, isOpen }: any) => { - const handleOnClose = () => { - handleClose(); - } +export interface CardImportDialogProps { + isOpen: boolean; + handleClose: () => void; +} +const CardImportDialog = ({ handleClose, isOpen }: CardImportDialogProps) => { return ( - + Import Cards - {handleOnClose ? ( - - - - ) : null} + + + - + ); diff --git a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx index 71b4020aa..09f0ffede 100644 --- a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx +++ b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx @@ -88,7 +88,20 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog helperText={error ?? ''} slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }} /> -
+
{ + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIdx((selectedIdx + 1) % SWATCHES.length); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIdx((selectedIdx - 1 + SWATCHES.length) % SWATCHES.length); + } + }} + > {SWATCHES.map((s, idx) => (
+ +
+ handleNameChange(e.target.value)} + error={error != null} + helperText={error ?? ''} + disabled={faceDown} + slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }} + /> + + Color + + + setPT(e.target.value.slice(0, MAX_PT_LEN))} + disabled={faceDown} + slotProps={{ htmlInput: { 'aria-label': 'Token power/toughness', maxLength: MAX_PT_LEN } }} + /> + setAnnotation(e.target.value.slice(0, MAX_ANNOTATION_LEN))} + slotProps={{ htmlInput: { 'aria-label': 'Token annotation', maxLength: MAX_ANNOTATION_LEN } }} + /> + setDestroyOnZoneChange(e.target.checked)} + slotProps={{ input: { 'aria-label': 'Destroy when it leaves the table' } }} + /> + } + label="Destroy when it leaves the table" + /> + setFaceDown(e.target.checked)} + slotProps={{ input: { 'aria-label': 'Create face-down' } }} + /> + } + label="Create face-down" + /> +
diff --git a/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts b/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts index 40aea51c8..4f5253f6e 100644 --- a/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts +++ b/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts @@ -1,7 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import { TokenDTO } from '@app/services'; import type { CreateTokenSubmit } from './CreateTokenDialog'; +export type ChooserScope = 'all' | 'deck'; + export interface CreateTokenDialogState { name: string; color: string; @@ -10,6 +14,17 @@ export interface CreateTokenDialogState { destroyOnZoneChange: boolean; faceDown: boolean; error: string | null; + + scope: ChooserScope; + search: string; + availableTokens: TokenDTO[]; + filteredTokens: TokenDTO[]; + selectedTokenName: string | null; + + setScope: (value: ChooserScope) => void; + setSearch: (value: string) => void; + selectPredefinedToken: (token: TokenDTO) => void; + handleNameChange: (value: string) => void; setColor: (value: string) => void; setPT: (value: string) => void; @@ -31,11 +46,39 @@ export const MAX_ANNOTATION_LEN = 255; export interface UseCreateTokenDialogArgs { isOpen: boolean; onSubmit: (args: CreateTokenSubmit) => void; + /** Optional deck-scoped token names; mirrors desktop DlgCreateToken predefinedTokens. */ + predefinedTokenNames?: string[]; +} + +/** Maps a MTGJSON-shaped color list ("W", "U", ...) to the dialog's single-letter color value. */ +function colorFromToken(token: TokenDTO): string { + const raw = token.prop?.value?.colors?.value ?? ''; + if (!raw) { + return ''; + } + const colors = raw.split(/[\s,]+/).filter(Boolean).map((c: string) => c.toLowerCase()); + if (colors.length === 0) { + return ''; + } + if (colors.length > 1) { + return 'm'; + } + const first = colors[0]; + if (first === 'w' || first === 'u' || first === 'b' || first === 'r' || first === 'g') { + return first; + } + return ''; +} + +/** Best-effort providerId from the token's first set entry; matches desktop TokenInfo.providerId. */ +function providerIdFromToken(token: TokenDTO): string | undefined { + return token.set?.[0]?.value ?? undefined; } export function useCreateTokenDialog({ isOpen, onSubmit, + predefinedTokenNames, }: UseCreateTokenDialogArgs): CreateTokenDialogState { const [name, setName] = useState(''); const [color, setColor] = useState(CREATE_TOKEN_DEFAULT_COLOR); @@ -45,6 +88,12 @@ export function useCreateTokenDialog({ const [faceDown, setFaceDown] = useState(false); const [error, setError] = useState(null); + const [scope, setScope] = useState(predefinedTokenNames?.length ? 'deck' : 'all'); + const [search, setSearch] = useState(''); + const [availableTokens, setAvailableTokens] = useState([]); + const [selectedTokenName, setSelectedTokenName] = useState(null); + const [providerId, setProviderId] = useState(undefined); + useEffect(() => { if (isOpen) { setName(''); @@ -54,9 +103,53 @@ export function useCreateTokenDialog({ setDestroyOnZoneChange(true); setFaceDown(false); setError(null); + setSearch(''); + setSelectedTokenName(null); + setProviderId(undefined); + setScope(predefinedTokenNames?.length ? 'deck' : 'all'); } + }, [isOpen, predefinedTokenNames]); + + useEffect(() => { + if (!isOpen) { + return; + } + let cancelled = false; + // Best-effort load of the token library. On failure the chooser renders + // empty and freeform creation still works. + import('@app/services').then(({ dexieService }) => { + dexieService.tokens.toArray().then((tokens: TokenDTO[]) => { + if (!cancelled) { + setAvailableTokens(tokens); + } + }).catch(() => { + if (!cancelled) { + setAvailableTokens([]); + } + }); + }); + return () => { + cancelled = true; + }; }, [isOpen]); + const filteredTokens = useMemo(() => { + const allowByScope = scope === 'deck' && predefinedTokenNames?.length + ? new Set(predefinedTokenNames.map((n) => n.toLowerCase())) + : null; + const needle = search.trim().toLowerCase(); + return availableTokens.filter((token) => { + const tokenName = token.name?.value ?? ''; + if (allowByScope && !allowByScope.has(tokenName.toLowerCase())) { + return false; + } + if (needle && !tokenName.toLowerCase().includes(needle)) { + return false; + } + return true; + }); + }, [availableTokens, scope, search, predefinedTokenNames]); + const handleNameChange = (value: string) => { setName(value.slice(0, MAX_NAME_LEN)); if (error) { @@ -64,20 +157,37 @@ export function useCreateTokenDialog({ } }; + const selectPredefinedToken = (token: TokenDTO) => { + const tokenName = token.name?.value ?? ''; + setSelectedTokenName(tokenName); + setName(tokenName.slice(0, MAX_NAME_LEN)); + setColor(colorFromToken(token)); + const ptRaw = token.prop?.value?.pt?.value ?? ''; + setPT(ptRaw.slice(0, MAX_PT_LEN)); + setProviderId(providerIdFromToken(token)); + if (error) { + setError(null); + } + }; + const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); if (name.trim().length === 0) { setError('Name is required'); return; } - onSubmit({ + const payload: CreateTokenSubmit = { name: name.trim(), color, pt: pt.trim(), annotation: annotation.trim(), destroyOnZoneChange, faceDown, - }); + }; + if (providerId) { + payload.providerId = providerId; + } + onSubmit(payload); }; return { @@ -88,6 +198,14 @@ export function useCreateTokenDialog({ destroyOnZoneChange, faceDown, error, + scope, + search, + availableTokens, + filteredTokens, + selectedTokenName, + setScope, + setSearch, + selectPredefinedToken, handleNameChange, setColor, setPT, diff --git a/webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.tsx b/webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.tsx index 68fc1bed0..669ad4777 100644 --- a/webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.tsx +++ b/webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.tsx @@ -21,6 +21,13 @@ import './FilterGamesDialog.css'; export interface FilterGamesDialogProps { isOpen: boolean; + /** + * MUST be a stable reference across renders while the dialog is open. + * The open-reset effect depends on `initialFilters` identity; an unstable + * reference (e.g. `{ ...defaults }` freshly constructed every parent render) + * will reset the draft form on every re-render. Pass a memoized value or a + * module-level constant. + */ initialFilters: GameFilters; gametypeMap: Enriched.GametypeMap; onCancel: () => void; diff --git a/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.css b/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.css index e8a350f0d..66cd7d989 100644 --- a/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.css +++ b/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.css @@ -1,26 +1,8 @@ -.KnownHostDialog { - -} - .KnownHostDialog .MuiDialog-paper { width: 100%; max-width: 420px; } -.dialog-title__wrapper { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid; - padding-bottom: 10px; -} - -.dialog-title__label { - display: flex; - align-items: center; -} - .dialog-content__subtitle.MuiTypography-root { margin-bottom: 20px; } diff --git a/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.tsx b/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.tsx index 193b0facd..8a7a9a9b2 100644 --- a/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.tsx +++ b/webclient/src/dialogs/KnownHostDialog/KnownHostDialog.tsx @@ -1,62 +1,40 @@ -import React from 'react'; -import { styled } from '@mui/material/styles'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; +import type { KnownHostFormValues } from '@app/forms'; import { KnownHostForm } from '@app/forms'; +import type { HostDTO } from '@app/services'; + +import AuthDialogShell from '../AuthDialogShell/AuthDialogShell'; import './KnownHostDialog.css'; -const PREFIX = 'KnownHostDialog'; +interface KnownHostDialogProps { + isOpen: boolean; + handleClose?: () => void; + onRemove: (host: HostDTO) => void; + onSubmit: (values: KnownHostFormValues) => void; + host?: HostDTO; +} -const classes = { - root: `${PREFIX}-root` -}; - -const StyledDialog = styled(Dialog)(({ theme }) => ({ - [`&.${classes.root}`]: { - '& .dialog-title__wrapper': { - borderColor: theme.palette.grey[300] - } - } -})); - -const KnownHostDialog = ({ handleClose, onRemove, onSubmit, isOpen, host }: any) => { +const KnownHostDialog = ({ handleClose, onRemove, onSubmit, isOpen, host }: KnownHostDialogProps) => { const { t } = useTranslation(); const mode = host ? 'edit' : 'add'; - const handleOnClose = () => { - if (handleClose) { - handleClose(); - } - }; - return ( - - -
- { t('KnownHostDialog.title', { mode }) } - - {handleClose ? ( - - - - ) : null} -
-
- - - { t('KnownHostDialog.subtitle') } - - - -
+ + + {t('KnownHostDialog.subtitle')} + + + ); }; diff --git a/webclient/src/dialogs/PromptDialog/usePromptDialog.ts b/webclient/src/dialogs/PromptDialog/usePromptDialog.ts index b00ec22ad..01e23500d 100644 --- a/webclient/src/dialogs/PromptDialog/usePromptDialog.ts +++ b/webclient/src/dialogs/PromptDialog/usePromptDialog.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -export interface PromptDialog { +export interface PromptDialogHandle { value: string; error: string | null; handleChange: (v: string) => void; @@ -19,7 +19,7 @@ export function usePromptDialog({ initialValue, validate, onSubmit, -}: UsePromptDialogArgs): PromptDialog { +}: UsePromptDialogArgs): PromptDialogHandle { const [value, setValue] = useState(initialValue); const [error, setError] = useState(null); diff --git a/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.css b/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.css index 73822f190..6ffc5e851 100644 --- a/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.css +++ b/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.css @@ -1,10 +1,4 @@ -.dialog-title { - display: flex; - justify-content: space-between; - align-items: center; -} - -.dialog-content { - width: 700px; - max-width: 100%; -} +.dialog-content { + width: 700px; + max-width: 100%; +} diff --git a/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.tsx b/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.tsx index a4ab8aef5..dd3a7941a 100644 --- a/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.tsx +++ b/webclient/src/dialogs/RegistrationDialog/RegistrationDialog.tsx @@ -1,38 +1,32 @@ -import React from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; -import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; +import type { RegisterFormValues } from '@app/forms'; import { RegisterForm } from '@app/forms'; +import AuthDialogShell from '../AuthDialogShell/AuthDialogShell'; + import './RegistrationDialog.css'; -const RegistrationDialog = ({ handleClose, isOpen, onSubmit }: any) => { +interface RegistrationDialogProps { + isOpen: boolean; + handleClose?: () => void; + onSubmit: (values: RegisterFormValues) => void; +} + +const RegistrationDialog = ({ handleClose, isOpen, onSubmit }: RegistrationDialogProps) => { const { t } = useTranslation(); - const handleOnClose = () => { - handleClose(); - } - return ( - - - { t('RegistrationDialog.title') } - - {handleOnClose ? ( - - - - ) : null} - - - - - + + + ); }; diff --git a/webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.tsx b/webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.tsx index 96c7b82a8..f0faa18e0 100644 --- a/webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.tsx +++ b/webclient/src/dialogs/RequestPasswordResetDialog/RequestPasswordResetDialog.tsx @@ -1,38 +1,33 @@ -import React from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; -import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; +import type { RequestPasswordResetFormValues } from '@app/forms'; import { RequestPasswordResetForm } from '@app/forms'; -import './RequestPasswordResetDialog.css'; +import AuthDialogShell from '../AuthDialogShell/AuthDialogShell'; -const RequestPasswordResetDialog = ({ handleClose, isOpen, onSubmit, skipTokenRequest }: any) => { +interface RequestPasswordResetDialogProps { + isOpen: boolean; + handleClose?: () => void; + onSubmit: (values: RequestPasswordResetFormValues) => void; + skipTokenRequest: (userName: string) => void; +} + +const RequestPasswordResetDialog = ({ + handleClose, + isOpen, + onSubmit, + skipTokenRequest, +}: RequestPasswordResetDialogProps) => { const { t } = useTranslation(); - const handleOnClose = () => { - handleClose(); - } - return ( - - - { t('RequestPasswordResetDialog.title') } - - {handleOnClose ? ( - - - - ) : null} - - - - - + + + ); }; diff --git a/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.css b/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.css deleted file mode 100644 index 731927c13..000000000 --- a/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.css +++ /dev/null @@ -1,5 +0,0 @@ -.dialog-title { - display: flex; - justify-content: space-between; - align-items: center; -} diff --git a/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.tsx b/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.tsx index 5c77aaef5..749666c87 100644 --- a/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.tsx +++ b/webclient/src/dialogs/ResetPasswordDialog/ResetPasswordDialog.tsx @@ -1,38 +1,28 @@ -import React from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; -import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; +import type { ResetPasswordFormValues } from '@app/forms'; import { ResetPasswordForm } from '@app/forms'; -import './ResetPasswordDialog.css'; +import AuthDialogShell from '../AuthDialogShell/AuthDialogShell'; -const ResetPasswordDialog = ({ handleClose, isOpen, onSubmit, userName }: any) => { +interface ResetPasswordDialogProps { + isOpen: boolean; + handleClose?: () => void; + onSubmit: (values: ResetPasswordFormValues) => void; + userName?: string; +} + +const ResetPasswordDialog = ({ handleClose, isOpen, onSubmit, userName }: ResetPasswordDialogProps) => { const { t } = useTranslation(); - const handleOnClose = () => { - handleClose(); - } - return ( - - - {t('ResetPasswordDialog.title')} - - {handleOnClose ? ( - - - - ) : null} - - - - - + + + ); }; diff --git a/webclient/src/dialogs/SideboardDialog/SideboardDialog.tsx b/webclient/src/dialogs/SideboardDialog/SideboardDialog.tsx index 3ab00bbcb..49744333e 100644 --- a/webclient/src/dialogs/SideboardDialog/SideboardDialog.tsx +++ b/webclient/src/dialogs/SideboardDialog/SideboardDialog.tsx @@ -8,6 +8,7 @@ import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; +import Tooltip from '@mui/material/Tooltip'; import { App, Enriched } from '@app/types'; @@ -84,19 +85,14 @@ function SideboardDialog({ }: SideboardDialogProps) { const [moves, setMoves] = useState([]); - // Reset the draft every time the dialog opens, and also when the server - // locks the sideboard mid-edit (desktop's resetSideboardPlan parity). + // Reset the draft whenever the dialog opens, or when the server locks the + // sideboard mid-edit (desktop's resetSideboardPlan parity). Consolidated + // into one effect keyed on both triggers. useEffect(() => { - if (isOpen) { + if (isOpen || isLocked) { setMoves([]); } - }, [isOpen]); - - useEffect(() => { - if (isLocked && moves.length > 0) { - setMoves([]); - } - }, [isLocked, moves.length]); + }, [isOpen, isLocked]); const { deck, sideboard } = useMemo( () => applyMoves(deckCards, sideboardCards, moves), @@ -166,15 +162,19 @@ function SideboardDialog({ {deck.map((card, idx) => (
  • {card.name} - + + + + +
  • ))} {deck.length === 0 && ( @@ -192,15 +192,19 @@ function SideboardDialog({
      {sideboard.map((card, idx) => (
    • - + + + + + {card.name}
    • ))} diff --git a/webclient/src/dialogs/index.ts b/webclient/src/dialogs/index.ts index 181cde568..178efa4c3 100644 --- a/webclient/src/dialogs/index.ts +++ b/webclient/src/dialogs/index.ts @@ -1,5 +1,7 @@ export { default as AccountActivationDialog } from './AccountActivationDialog/AccountActivationDialog'; export { default as AlertDialog } from './AlertDialog/AlertDialog'; +export { default as AuthDialogShell } from './AuthDialogShell/AuthDialogShell'; +export type { AuthDialogShellProps } from './AuthDialogShell/AuthDialogShell'; export type { AlertDialogProps, AlertDialogSeverity } from './AlertDialog/AlertDialog'; export { default as CardImportDialog } from './CardImportDialog/CardImportDialog'; export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog'; diff --git a/webclient/src/forms/AccountActivationForm/AccountActivationForm.tsx b/webclient/src/forms/AccountActivationForm/AccountActivationForm.tsx index 4ffc9e51a..d17d72502 100644 --- a/webclient/src/forms/AccountActivationForm/AccountActivationForm.tsx +++ b/webclient/src/forms/AccountActivationForm/AccountActivationForm.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React, { useState } from "react"; +import { useState } from 'react'; import { Form, Field } from 'react-final-form'; import { useTranslation } from 'react-i18next'; @@ -7,12 +6,21 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import { InputField } from '@app/components'; +import type { FormErrors } from '@app/forms'; import { useReduxEffect } from '@app/hooks'; import { ServerTypes } from '@app/store'; import './AccountActivationForm.css'; -const AccountActivationForm = ({ onSubmit }) => { +export interface AccountActivationFormValues { + token: string; +} + +interface AccountActivationFormProps { + onSubmit: (values: AccountActivationFormValues) => void; +} + +const AccountActivationForm = ({ onSubmit }: AccountActivationFormProps) => { const [errorMessage, setErrorMessage] = useState(false); const { t } = useTranslation(); @@ -20,16 +28,14 @@ const AccountActivationForm = ({ onSubmit }) => { setErrorMessage(true); }, ServerTypes.ACCOUNT_ACTIVATION_FAILED, []); - const handleOnSubmit = ({ token, ...values }) => { + const handleOnSubmit = ({ token, ...values }: AccountActivationFormValues) => { setErrorMessage(false); - token = token?.trim(); + onSubmit({ ...values, token: token?.trim() }); + }; - onSubmit({ token, ...values }); - } - - const validate = values => { - const errors: any = {}; + const validate = (values: Partial): FormErrors => { + const errors: FormErrors = {}; if (!values.token) { errors.token = t('Common.validation.required'); diff --git a/webclient/src/forms/CardImportForm/CardImportForm.i18n.json b/webclient/src/forms/CardImportForm/CardImportForm.i18n.json new file mode 100644 index 000000000..92249b8c0 --- /dev/null +++ b/webclient/src/forms/CardImportForm/CardImportForm.i18n.json @@ -0,0 +1,25 @@ +{ + "CardImportForm": { + "steps": { + "importSets": "Import sets", + "saveSets": "Save sets", + "importTokens": "Import tokens", + "finished": "Finished" + }, + "label": { + "downloadUrl": "Download URL" + }, + "button": { + "import": "Import", + "save": "Save", + "goBack": "Go Back", + "done": "Done" + }, + "message": { + "finished": "Finished!", + "importSummary": "Import finished: {count} cards.", + "setSummary": "{name}: {count} cards imported", + "failedToSave": "Failed to save cards" + } + } +} diff --git a/webclient/src/forms/CardImportForm/CardImportForm.tsx b/webclient/src/forms/CardImportForm/CardImportForm.tsx index 1f609812f..6ec114054 100644 --- a/webclient/src/forms/CardImportForm/CardImportForm.tsx +++ b/webclient/src/forms/CardImportForm/CardImportForm.tsx @@ -1,4 +1,6 @@ +import { ReactNode } from 'react'; import { Form, Field } from 'react-final-form'; +import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; import Stepper from '@mui/material/Stepper'; @@ -7,12 +9,72 @@ import StepLabel from '@mui/material/StepLabel'; import CircularProgress from '@mui/material/CircularProgress'; import { InputField, VirtualList } from '@app/components'; +import type { App } from '@app/types'; import { useCardImportForm } from './useCardImportForm'; import './CardImportForm.css'; -const CardImportForm = ({ onSubmit: onClose }) => { +const CARDS_URL = 'https://www.mtgjson.com/api/v5/AllPrintings.json'; +const TOKENS_URL = 'https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml'; + +interface CardImportFormProps { + onSubmit: () => void; +} + +interface BackButtonProps { + click: () => void; + disabled?: boolean; +} + +const BackButton = ({ click, disabled }: BackButtonProps) => { + const { t } = useTranslation(); + return ( + + ); +}; + +interface ErrorMessageProps { + error: string | null; +} + +const ErrorMessage = ({ error }: ErrorMessageProps): ReactNode => ( + error ?
      {error}
      : null +); + +interface CardsImportedProps { + cards: App.Card[]; + sets: App.Set[]; +} + +const CardsImported = ({ cards, sets }: CardsImportedProps) => { + const { t } = useTranslation(); + const items: ReactNode[] = [ + ( +
      + {t('CardImportForm.message.importSummary', { count: cards.length })} +
      + ), + (
      ), + ...sets.map(set => ( +
      + {t('CardImportForm.message.setSummary', { name: set.name, count: set.cards.length })} +
      + )), + ]; + + return ( +
      + +
      + ); +}; + +const CardImportForm = ({ onSubmit: onClose }: CardImportFormProps) => { + const { t } = useTranslation(); const { loading, activeStep, @@ -25,24 +87,29 @@ const CardImportForm = ({ onSubmit: onClose }) => { handleTokenDownload, } = useCardImportForm(); - const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished']; + const steps = [ + t('CardImportForm.steps.importSets'), + t('CardImportForm.steps.saveSets'), + t('CardImportForm.steps.importTokens'), + t('CardImportForm.steps.finished'), + ]; - const getStepContent = (stepIndex) => { + const getStepContent = (stepIndex: number): ReactNode => { switch (stepIndex) { case 0: return ( {({ handleSubmit }) => (
      - +
      @@ -63,7 +130,7 @@ const CardImportForm = ({ onSubmit: onClose }) => {
      @@ -76,18 +143,18 @@ const CardImportForm = ({ onSubmit: onClose }) => { case 2: return ( {({ handleSubmit }) => (
      - +
      @@ -101,14 +168,17 @@ const CardImportForm = ({ onSubmit: onClose }) => { case 3: return (
      -
      Finished!
      +
      {t('CardImportForm.message.finished')}
      - +
      ); + + default: + throw new Error(`CardImportForm: unknown step index ${stepIndex}`); } }; @@ -135,39 +205,4 @@ const CardImportForm = ({ onSubmit: onClose }) => { ); }; -const BackButton = ({ click, disabled }) => ( - -); - -const ErrorMessage = ({ error }) => { - return error && ( -
      {error}
      - ); -}; - -const CardsImported = ({ cards, sets }) => { - const items = [ - ( -
      - Import finished: {cards.length} cards. -
      - ), - - (
      ), - - ...sets.map(set => ( -
      {set.name}: {set.cards.length} cards imported
      - )) - ]; - - return ( -
      - -
      - ); -}; - export default CardImportForm; diff --git a/webclient/src/forms/CardImportForm/useCardImportForm.ts b/webclient/src/forms/CardImportForm/useCardImportForm.ts index 33cbb5012..404f2f9d3 100644 --- a/webclient/src/forms/CardImportForm/useCardImportForm.ts +++ b/webclient/src/forms/CardImportForm/useCardImportForm.ts @@ -1,12 +1,13 @@ import { useEffect, useState } from 'react'; import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services'; +import type { App } from '@app/types'; export interface CardImportForm { loading: boolean; activeStep: number; - importedCards: any[]; - importedSets: any[]; + importedCards: App.Card[]; + importedSets: App.Set[]; error: string | null; handleNext: () => void; handleBack: () => void; @@ -18,8 +19,8 @@ export interface CardImportForm { export function useCardImportForm(): CardImportForm { const [loading, setLoading] = useState(false); const [activeStep, setActiveStep] = useState(0); - const [importedCards, setImportedCards] = useState([]); - const [importedSets, setImportedSets] = useState([]); + const [importedCards, setImportedCards] = useState([]); + const [importedSets, setImportedSets] = useState([]); const [error, setError] = useState(null); useEffect(() => { diff --git a/webclient/src/forms/KnownHostForm/KnownHostForm.tsx b/webclient/src/forms/KnownHostForm/KnownHostForm.tsx index 86dc4f85e..ae07f8dd5 100644 --- a/webclient/src/forms/KnownHostForm/KnownHostForm.tsx +++ b/webclient/src/forms/KnownHostForm/KnownHostForm.tsx @@ -1,21 +1,35 @@ -// eslint-disable-next-line -import React, { useState } from "react"; -import { Form, Field } from 'react-final-form' +import { useState } from 'react'; +import { Form, Field } from 'react-final-form'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; import AnchorLink from '@mui/material/Link'; import { InputField } from '@app/components'; +import type { FormErrors } from '@app/forms'; +import type { HostDTO } from '@app/services'; import './KnownHostForm.css'; -const KnownHostForm = ({ host, onRemove, onSubmit }) => { +export interface KnownHostFormValues { + id?: number; + name: string; + host: string; + port: string; +} + +interface KnownHostFormProps { + host?: HostDTO; + onRemove: (host: HostDTO) => void; + onSubmit: (values: KnownHostFormValues) => void; +} + +const KnownHostForm = ({ host, onRemove, onSubmit }: KnownHostFormProps) => { const [confirmDelete, setConfirmDelete] = useState(false); const { t } = useTranslation(); - const validate = values => { - const errors: any = {}; + const validate = (values: Partial): FormErrors => { + const errors: FormErrors = {}; if (!values.name) { errors.name = t('Common.validation.required'); @@ -29,17 +43,27 @@ const KnownHostForm = ({ host, onRemove, onSubmit }) => { errors.port = t('Common.validation.required'); } - if (Object.keys(errors).length) { - return errors; - } + return errors; }; - const handleOnSubmit = ({ name, host, ...values }) => { - name = name?.trim(); - host = host?.trim(); + const handleOnSubmit = ({ name, host: hostValue, ...values }: KnownHostFormValues) => { + onSubmit({ + ...values, + name: name?.trim(), + host: hostValue?.trim(), + }); + }; - onSubmit({ name, host, ...values }); - } + const handleRemoveClick = () => { + if (!host) { + return; + } + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + onRemove(host); + }; return ( {
      { host && ( - ) } diff --git a/webclient/src/forms/LoginForm/LoginForm.css b/webclient/src/forms/LoginForm/LoginForm.css index 4b176476f..9d950caa0 100644 --- a/webclient/src/forms/LoginForm/LoginForm.css +++ b/webclient/src/forms/LoginForm/LoginForm.css @@ -18,3 +18,9 @@ .loginForm-submit { width: 100%; } + +.loginForm-loading { + display: flex; + justify-content: center; + padding: 40px 0; +} diff --git a/webclient/src/forms/LoginForm/LoginForm.i18n.json b/webclient/src/forms/LoginForm/LoginForm.i18n.json index 260b88b27..d379ee0ca 100644 --- a/webclient/src/forms/LoginForm/LoginForm.i18n.json +++ b/webclient/src/forms/LoginForm/LoginForm.i18n.json @@ -5,7 +5,7 @@ "forgot": "Forgot Password", "login": "Login", "savePassword": "Save Password", - "savedPassword": "Saved Password" + "savedPassword": "* Saved Password *" } } } diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index e4076289c..b301c2730 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -5,17 +5,28 @@ import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; import Checkbox from '@mui/material/Checkbox'; +import CircularProgress from '@mui/material/CircularProgress'; import FormControlLabel from '@mui/material/FormControlLabel'; import { CheckboxField, InputField, KnownHosts } from '@app/components'; +import type { FormErrors } from '@app/forms'; import { LoadingState, useKnownHosts, useSettings } from '@app/hooks'; +import { HostDTO } from '@app/services'; import { useLoginFormBody } from './useLoginForm'; import './LoginForm.css'; +export interface LoginFormValues { + userName: string; + password: string; + remember: boolean; + autoConnect: boolean; + selectedHost: HostDTO; +} + interface LoginFormProps { - onSubmit: (values: any) => void; + onSubmit: (values: LoginFormValues) => void; disableSubmitButton: boolean; onResetPassword: () => void; } @@ -33,7 +44,7 @@ const LoginFormBody = ({ }: LoginFormBodyProps) => { const { t } = useTranslation(); const PASSWORD_LABEL = t('Common.label.password'); - const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`; + const STORED_PASSWORD_LABEL = t('LoginForm.label.savedPassword'); const { useStoredPasswordLabel, @@ -121,8 +132,8 @@ const LoginForm = (props: LoginFormProps) => { const knownHosts = useKnownHosts(); const settings = useSettings(); - const validate = (values: any) => { - const errors: any = {}; + const validate = (values: Partial): FormErrors => { + const errors: FormErrors = {}; if (!values.userName) { errors.userName = t('Common.validation.required'); @@ -134,17 +145,20 @@ const LoginForm = (props: LoginFormProps) => { return errors; }; - const handleOnSubmit = ({ userName, ...values }: any) => { - userName = userName?.trim(); - props.onSubmit({ userName, ...values }); + const handleOnSubmit = ({ userName, ...values }: LoginFormValues) => { + props.onSubmit({ ...values, userName: userName?.trim() }); }; if (knownHosts.status !== LoadingState.READY || settings.status !== LoadingState.READY) { - return null; + return ( +
      + +
      + ); } const selectedHost = knownHosts.value?.selectedHost; - const initialValues = { + const initialValues: Partial = { selectedHost, userName: selectedHost?.userName ?? '', remember: Boolean(selectedHost?.remember), diff --git a/webclient/src/forms/RegisterForm/RegisterForm.tsx b/webclient/src/forms/RegisterForm/RegisterForm.tsx index 21e60bf24..62646867d 100644 --- a/webclient/src/forms/RegisterForm/RegisterForm.tsx +++ b/webclient/src/forms/RegisterForm/RegisterForm.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Form, Field } from 'react-final-form'; +import { Form, Field, useForm } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; import setFieldTouched from 'final-form-set-field-touched'; import { useTranslation } from 'react-i18next'; @@ -8,12 +8,42 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import { CountryDropdown, InputField, KnownHosts } from '@app/components'; +import type { FormErrors } from '@app/forms'; +import type { HostDTO } from '@app/services'; import { ServerDispatch } from '@app/store'; import { useRegisterForm } from './useRegisterForm'; import './RegisterForm.css'; +export interface RegisterFormValues { + userName: string; + password: string; + passwordConfirm: string; + email?: string; + emailConfirm?: string; + realName?: string; + country?: string; + selectedHost: HostDTO; +} + +interface RegisterFormProps { + onSubmit: (values: RegisterFormValues) => void; +} + +// Drives `setFieldTouched` from inside the react-final-form context so the +// hook lives in a real component body instead of the render prop, +// where react-final-form might short-circuit rendering and desync hook order. +const EmailTouchOnRequire = ({ emailRequired }: { emailRequired: boolean }) => { + const form = useForm(); + useEffect(() => { + if (emailRequired) { + form.mutators.setFieldTouched('email', true); + } + }, [emailRequired, form]); + return null; +}; + const RegisterForm = ({ onSubmit }: RegisterFormProps) => { const { t } = useTranslation(); const { @@ -28,18 +58,19 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => { onUserNameChange, } = useRegisterForm(); - const handleOnSubmit = ({ userName, email, realName, ...values }) => { + const handleOnSubmit = (values: RegisterFormValues) => { ServerDispatch.clearRegistrationErrors(); - userName = userName?.trim(); - email = email?.trim(); - realName = realName?.trim(); - - onSubmit({ userName, email, realName, ...values }); + onSubmit({ + ...values, + userName: values.userName?.trim(), + email: values.email?.trim(), + realName: values.realName?.trim(), + }); }; - const validate = values => { - const errors: any = {}; + const validate = (values: Partial): FormErrors => { + const errors: FormErrors = {}; if (!values.userName) { errors.userName = t('Common.validation.required'); @@ -71,83 +102,87 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => { errors.email = emailError; } + if (emailRequired) { + if (!values.emailConfirm) { + errors.emailConfirm = t('Common.validation.required'); + } else if (values.email !== values.emailConfirm) { + errors.emailConfirm = t('Common.validation.emailsMustMatch'); + } + } + return errors; }; return ( - {({ handleSubmit, form }) => { - - useEffect(() => { - if (emailRequired) { - form.mutators.setFieldTouched('email', true); - } - }, [emailRequired]); - - return ( - <> - -
      -
      - - {onUserNameChange} -
      -
      - - {onPasswordChange} -
      -
      - -
      -
      - - {onHostChange} -
      -
      -
      -
      - -
      -
      - - {onEmailChange} -
      -
      - -
      - -
      - - - {error && ( + {({ handleSubmit }) => ( + <> + +
      +
      - {error} + + {onUserNameChange}
      - )} - - ); - }} +
      + + {onPasswordChange} +
      +
      + +
      +
      + + {onHostChange} +
      +
      +
      +
      + +
      +
      + + {onEmailChange} +
      +
      + +
      +
      + +
      + +
      +
      + + {error && ( +
      + {error} +
      + )} + + )} ); }; -interface RegisterFormProps { - onSubmit: any; -} - export default RegisterForm; diff --git a/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx b/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx index 83a686dca..5132fac67 100644 --- a/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx +++ b/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx @@ -1,4 +1,5 @@ -import { Form, Field } from 'react-final-form'; +import { useCallback } from 'react'; +import { Form, Field, FormApi } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; import { useTranslation } from 'react-i18next'; @@ -6,26 +7,44 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import { InputField, KnownHosts } from '@app/components'; +import type { FormErrors } from '@app/forms'; +import { HostDTO } from '@app/services'; import { useRequestPasswordResetForm } from './useRequestPasswordResetForm'; import './RequestPasswordResetForm.css'; -const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => { +export interface RequestPasswordResetFormValues { + userName: string; + email?: string; + selectedHost: HostDTO; +} + +interface RequestPasswordResetFormProps { + onSubmit: (values: RequestPasswordResetFormValues) => void; + skipTokenRequest: (userName: string) => void; +} + +interface HostChangePayload { + userName?: string; +} + +const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }: RequestPasswordResetFormProps) => { const { t } = useTranslation(); const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm(); - const handleOnSubmit = ({ userName, email, ...values }) => { + const handleOnSubmit = ({ userName, email, ...values }: RequestPasswordResetFormValues) => { setErrorMessage(false); - userName = userName?.trim(); - email = email?.trim(); - - onSubmit({ userName, email, ...values }); + onSubmit({ + ...values, + userName: userName?.trim(), + email: email?.trim(), + }); }; - const validate = values => { - const errors: any = {}; + const validate = (values: Partial): FormErrors => { + const errors: FormErrors = {}; if (!values.userName) { errors.userName = t('Common.validation.required'); @@ -42,50 +61,79 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => { return (
      - {({ handleSubmit, form }) => { - const onHostChange: any = ({ userName }) => { - form.change('userName', userName); - setIsMFA(false); - }; - - return ( - -
      -
      - -
      - {isMFA ? ( -
      - -
      {t('RequestPasswordResetForm.mfaEnabled')}
      -
      - ) : null} -
      - - {onHostChange} -
      - - {errorMessage && ( -
      - {t('RequestPasswordResetForm.error')} -
      - )} -
      - - - -
      - -
      -
      - ); - }} + {({ handleSubmit, form }) => ( + + )} ); }; +interface BodyProps { + handleSubmit: (event?: React.SyntheticEvent) => void; + form: FormApi; + errorMessage: boolean; + isMFA: boolean; + setIsMFA: (v: boolean) => void; + skipTokenRequest: (userName: string) => void; +} + +const RequestPasswordResetFormBody = ({ + handleSubmit, + form, + errorMessage, + isMFA, + setIsMFA, + skipTokenRequest, +}: BodyProps) => { + const { t } = useTranslation(); + + const onHostChange = useCallback(({ userName }: HostChangePayload) => { + form.change('userName', userName); + setIsMFA(false); + }, [form, setIsMFA]); + + return ( +
      +
      +
      + +
      + {isMFA ? ( +
      + +
      {t('RequestPasswordResetForm.mfaEnabled')}
      +
      + ) : null} +
      + + {onHostChange} +
      + + {errorMessage && ( +
      + {t('RequestPasswordResetForm.error')} +
      + )} +
      + + + +
      + +
      +
      + ); +}; + export default RequestPasswordResetForm; diff --git a/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx b/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx index d00681176..0a2162bc3 100644 --- a/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx +++ b/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx @@ -5,17 +5,32 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import { InputField, KnownHosts } from '@app/components'; +import type { FormErrors } from '@app/forms'; +import { HostDTO } from '@app/services'; import { useResetPasswordForm } from './useResetPasswordForm'; import './ResetPasswordForm.css'; -const ResetPasswordForm = ({ onSubmit, userName }) => { +export interface ResetPasswordFormValues { + userName: string; + token: string; + newPassword: string; + passwordAgain: string; + selectedHost: HostDTO; +} + +interface ResetPasswordFormProps { + onSubmit: (values: ResetPasswordFormValues) => void; + userName?: string; +} + +const ResetPasswordForm = ({ onSubmit, userName }: ResetPasswordFormProps) => { const { t } = useTranslation(); const { errorMessage } = useResetPasswordForm(); - const validate = values => { - const errors: any = {}; + const validate = (values: Partial): FormErrors => { + const errors: FormErrors = {}; if (!values.userName) { errors.userName = t('Common.validation.required'); @@ -42,11 +57,12 @@ const ResetPasswordForm = ({ onSubmit, userName }) => { return errors; }; - const handleOnSubmit = ({ userName, token, ...values }) => { - userName = userName?.trim(); - token = token?.trim(); - - onSubmit({ userName, token, ...values }); + const handleOnSubmit = ({ userName: uName, token, ...values }: ResetPasswordFormValues) => { + onSubmit({ + ...values, + userName: uName?.trim(), + token: token?.trim(), + }); }; return ( @@ -60,7 +76,7 @@ const ResetPasswordForm = ({ onSubmit, userName }) => { name='userName' component={InputField} autoComplete='username' - disabled={!!userName} + InputProps={{ readOnly: Boolean(userName) }} />
      diff --git a/webclient/src/forms/SearchForm/SearchForm.i18n.json b/webclient/src/forms/SearchForm/SearchForm.i18n.json new file mode 100644 index 000000000..37c586d19 --- /dev/null +++ b/webclient/src/forms/SearchForm/SearchForm.i18n.json @@ -0,0 +1,17 @@ +{ + "SearchForm": { + "label": { + "userName": "Username", + "ipAddress": "IP Address", + "gameName": "Game Name", + "gameId": "GameID", + "message": "Message", + "rooms": "Rooms", + "games": "Games", + "chats": "Chats" + }, + "button": { + "search": "Search Logs" + } + } +} diff --git a/webclient/src/forms/SearchForm/SearchForm.tsx b/webclient/src/forms/SearchForm/SearchForm.tsx index 07b1792ad..371ed1bf6 100644 --- a/webclient/src/forms/SearchForm/SearchForm.tsx +++ b/webclient/src/forms/SearchForm/SearchForm.tsx @@ -1,6 +1,5 @@ -// eslint-disable-next-line -import React from "react"; import { Form, Field } from 'react-final-form'; +import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; @@ -10,48 +9,61 @@ import { InputField, CheckboxField } from '@app/components'; import './SearchForm.css'; -const SearchForm = ({ onSubmit }) => ( -
      - {({ handleSubmit }) => ( - - -
      - -
      -
      - -
      -
      - -
      -
      - -
      -
      - -
      - -
      - - - -
      - -
      - Date Range: Coming Soon -
      - -
      - Maximum Results: 1000 -
      - - - -
      - )} - -); +export interface SearchFormValues { + userName?: string; + ipAddress?: string; + gameName?: string; + gameId?: string; + message?: string; + logLocation?: { + room?: boolean; + game?: boolean; + chat?: boolean; + }; +} + +interface SearchFormProps { + onSubmit: (values: SearchFormValues) => void; +} + +const SearchForm = ({ onSubmit }: SearchFormProps) => { + const { t } = useTranslation(); + + return ( +
      + {({ handleSubmit }) => ( + + +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      + +
      + + + +
      + + + +
      + )} + + ); +}; export default SearchForm; diff --git a/webclient/src/forms/index.ts b/webclient/src/forms/index.ts index b922a9f40..772b76d6a 100644 --- a/webclient/src/forms/index.ts +++ b/webclient/src/forms/index.ts @@ -1,3 +1,5 @@ +export type { FormErrors } from './types'; + export { default as AccountActivationForm } from './AccountActivationForm/AccountActivationForm'; export { default as CardImportForm } from './CardImportForm/CardImportForm'; export { default as LoginForm } from './LoginForm/LoginForm'; diff --git a/webclient/src/forms/types.ts b/webclient/src/forms/types.ts new file mode 100644 index 000000000..972d636dd --- /dev/null +++ b/webclient/src/forms/types.ts @@ -0,0 +1 @@ +export type FormErrors = Partial>; diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.ts b/webclient/src/hooks/useFireOnce/useFireOnce.ts index e4f5265de..df2bb2a17 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.ts +++ b/webclient/src/hooks/useFireOnce/useFireOnce.ts @@ -1,8 +1,8 @@ import { useCallback, useRef, useState } from 'react'; -type UseFireOnceType = (...args: any) => any; +type FireOnceFn = (...args: never[]) => unknown; -export function useFireOnce(fn: T): [boolean, () => void, (...args: Parameters) => void] { +export function useFireOnce(fn: T): [boolean, () => void, (...args: Parameters) => void] { const [actionIsInFlight, setActionIsInFlight] = useState(false); const fnRef = useRef(fn); fnRef.current = fn; diff --git a/webclient/src/hooks/useKnownHosts.spec.ts b/webclient/src/hooks/useKnownHosts.spec.ts index 40de6a8af..92ee17317 100644 --- a/webclient/src/hooks/useKnownHosts.spec.ts +++ b/webclient/src/hooks/useKnownHosts.spec.ts @@ -106,7 +106,7 @@ beforeEach(async () => { }); describe('useKnownHosts', () => { - test('seeds DefaultHosts when the DB is empty and pins hosts[0] as lastSelected', async () => { + test('seeds DefaultHosts when the DB is empty and picks hosts[0] as selected', async () => { const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); await waitFor(() => { @@ -118,7 +118,9 @@ describe('useKnownHosts', () => { } expect(result.current.value.hosts).toHaveLength(2); expect(result.current.value.selectedHost.name).toBe('A'); - expect(result.current.value.selectedHost.lastSelected).toBe(true); + // normalize no longer auto-persists `lastSelected = true`; it's set on + // explicit `select()` / `remove()` paths instead. Seed-time selection + // is in-memory only until the user picks. }); test('select(id) flips lastSelected atomically — exactly one row true', async () => { diff --git a/webclient/src/hooks/useKnownHosts.ts b/webclient/src/hooks/useKnownHosts.ts index d3ebf0c29..3183ed5cf 100644 --- a/webclient/src/hooks/useKnownHosts.ts +++ b/webclient/src/hooks/useKnownHosts.ts @@ -17,22 +17,16 @@ const loadAll = async (): Promise => { return hosts; }; -const normalize = async (hosts: HostDTO[]): Promise => { +const normalize = (hosts: HostDTO[]): KnownHostsValue => { const existing = hosts.find((h) => h.lastSelected); - if (existing) { - return { hosts, selectedHost: existing }; - } - - const selected = hosts[0]; - selected.lastSelected = true; - await selected.save(); - return { hosts, selectedHost: selected }; + return { hosts, selectedHost: existing ?? hosts[0] }; }; export const knownHostsStore = createSharedStore(async () => { const hosts = await loadAll(); return normalize(hosts); }); + const store = knownHostsStore; export type KnownHostsHook = Loadable & { @@ -98,7 +92,7 @@ const update = async (id: number, patch: Partial): Promise => const remove = async (id: number): Promise => { const { hosts, selectedHost } = requireValue('remove'); - await HostDTO.delete(id as unknown as string); + await HostDTO.delete(id); const next = hosts.filter((h) => h.id !== id); let nextSelected = selectedHost; if (selectedHost.id === id) { diff --git a/webclient/src/hooks/useReduxEffect.tsx b/webclient/src/hooks/useReduxEffect.tsx index f0b6b3e35..278b63632 100644 --- a/webclient/src/hooks/useReduxEffect.tsx +++ b/webclient/src/hooks/useReduxEffect.tsx @@ -7,10 +7,15 @@ File is adapted from https://github.com/Qeepsake/use-redux-effect under MIT Lice import { useEffect, useRef, DependencyList } from 'react' import { useStore } from 'react-redux' -// Actions are identified by string `type` at runtime, so the callback -// receives an untyped action object to allow free property access. +export interface ReduxEffectAction

      { + type: string; + payload: P; + meta: unknown; + error: boolean; + count: number; +} -export type ReduxEffect = (action: any) => void +export type ReduxEffect

      = (action: ReduxEffectAction

      ) => void; /** * Subscribes to redux store events. @@ -20,8 +25,8 @@ export type ReduxEffect = (action: any) => void * what lets `` catch a `JOIN_ROOM` that auto-join fired while the * route was transitioning. */ -export function useReduxEffect( - effect: ReduxEffect, +export function useReduxEffect

      ( + effect: ReduxEffect

      , type: string | string[], deps: DependencyList = [], ): void { @@ -37,7 +42,7 @@ export function useReduxEffect( useEffect(() => { const check = (): void => { - const action = (store.getState() as any).action; + const action = (store.getState() as { action?: ReduxEffectAction

      }).action; if (!action || action.count === lastHandledCountRef.current) { return; } diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx index 3d9c02697..0d3706d67 100644 --- a/webclient/src/hooks/useWebClient.tsx +++ b/webclient/src/hooks/useWebClient.tsx @@ -3,6 +3,7 @@ import { WebClient } from '@app/websocket'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; export const WebClientContext = createContext(null); +WebClientContext.displayName = 'WebClientContext'; export function WebClientProvider({ children }: { children: ReactNode }) { const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); diff --git a/webclient/src/i18n-default.json b/webclient/src/i18n-default.json index 3c63e0abb..c38b91fdc 100644 --- a/webclient/src/i18n-default.json +++ b/webclient/src/i18n-default.json @@ -3,6 +3,7 @@ "language": "English", "disconnect": "Disconnect", "label": { + "confirmEmail": "Confirm Email", "confirmPassword": "Confirm Password", "confirmSure": "Are you sure?", "country": "Country", @@ -19,6 +20,7 @@ "username": "Username" }, "validation": { + "emailsMustMatch": "Emails don't match", "minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required", "passwordsMustMatch": "Passwords don't match", "required": "Required" @@ -312,6 +314,55 @@ "accountActivationSuccess": "Account Activated Successfully" } }, + "Logs": { + "tab": { + "rooms": "Rooms", + "games": "Games", + "chats": "Chats" + }, + "column": { + "time": "Time", + "senderName": "Sender Name", + "senderIp": "Sender IP", + "message": "Message", + "targetId": "Target ID", + "targetName": "Target Name" + }, + "message": { + "emptyFilter": "Enter at least one search field before submitting." + } + }, + "Player": { + "title": "User Information", + "label": { + "realName": "Real Name", + "location": "Location", + "userLevel": "User Level", + "accountAge": "Account Age" + }, + "level": { + "administrator": "Administrator", + "moderator": "Moderator", + "registered": "Registered user", + "unregistered": "Unregistered user", + "judge": "Judge" + }, + "age": { + "unknown": "Unknown", + "days": "{count, plural, one {# day} other {# days}}", + "daysWithYears": "{years, plural, one {# year} other {# years}}, {days, plural, one {# day} other {# days}}" + }, + "action": { + "addBuddy": "Add to Buddy List", + "removeBuddy": "Remove from Buddy List", + "addIgnore": "Add to Ignore List", + "removeIgnore": "Remove from Ignore List", + "message": "Message", + "warn": "Warn User", + "ban": "Ban from Server", + "notFound": "User not found or still loading…" + } + }, "UnsupportedContainer": { "title": "Unsupported Browser", "subtitle1": "Please update your browser and/or check your permissions.", @@ -343,6 +394,29 @@ "activate": "Activate Account" } }, + "CardImportForm": { + "steps": { + "importSets": "Import sets", + "saveSets": "Save sets", + "importTokens": "Import tokens", + "finished": "Finished" + }, + "label": { + "downloadUrl": "Download URL" + }, + "button": { + "import": "Import", + "save": "Save", + "goBack": "Go Back", + "done": "Done" + }, + "message": { + "finished": "Finished!", + "importSummary": "Import finished: {count} cards.", + "setSummary": "{name}: {count} cards imported", + "failedToSave": "Failed to save cards" + } + }, "KnownHostForm": { "help": "Need help adding a new host?", "label": { @@ -356,7 +430,7 @@ "forgot": "Forgot Password", "login": "Login", "savePassword": "Save Password", - "savedPassword": "Saved Password" + "savedPassword": "* Saved Password *" } }, "RegisterForm": { @@ -378,5 +452,20 @@ "label": { "reset": "Reset Password" } + }, + "SearchForm": { + "label": { + "userName": "Username", + "ipAddress": "IP Address", + "gameName": "Game Name", + "gameId": "GameID", + "message": "Message", + "rooms": "Rooms", + "games": "Games", + "chats": "Chats" + }, + "button": { + "search": "Search Logs" + } } } \ No newline at end of file diff --git a/webclient/src/index.css b/webclient/src/index.css index c5f9b9230..612434ab8 100644 --- a/webclient/src/index.css +++ b/webclient/src/index.css @@ -1,9 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Teko&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap'); - -:root { - -} +@import url('https://fonts.googleapis.com/css2?family=Teko&family=Open+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap'); * { box-sizing: border-box; diff --git a/webclient/src/index.tsx b/webclient/src/index.tsx index 62235bf62..0fed235f8 100644 --- a/webclient/src/index.tsx +++ b/webclient/src/index.tsx @@ -26,7 +26,7 @@ const AppWithMaterialTheme = () => { ); -} +}; const container = document.getElementById('root'); const root = createRoot(container!); diff --git a/webclient/src/material-theme.ts b/webclient/src/material-theme.ts index 8f646c85a..efdc5ec30 100644 --- a/webclient/src/material-theme.ts +++ b/webclient/src/material-theme.ts @@ -42,13 +42,6 @@ export const materialTheme = createTheme({ palette, components: { - MuiCssBaseline: { - styleOverrides: { - '@global': { - '@font-face': [], - }, - }, - }, MuiButton: { styleOverrides: { root: { diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts index 20d155214..02412844a 100644 --- a/webclient/src/services/CardImporterService.ts +++ b/webclient/src/services/CardImporterService.ts @@ -31,7 +31,7 @@ class CardImporterService { tokens: tokens.map(({ name }) => name), })); - const unsortedCards = sortedSets.reduce((acc, set) => { + const unsortedCards = sortedSets.reduce>((acc, set) => { set.cards.forEach(card => acc[card.name] = card); return acc; }, {}); @@ -56,7 +56,7 @@ class CardImporterService { throw new Error('Failed to fetch'); } - return response.text() + return response.text(); }) .then((xmlString) => { try { @@ -75,17 +75,17 @@ class CardImporterService { } catch (err) { throw new Error(error, { cause: err }); } - }) + }); } private parseXmlAttributes(dom: Element) { - return Array.from(dom.children).reduce((attributes, child) => { + return Array.from(dom.children).reduce>((attributes, child) => { const value = child.children.length ? this.parseXmlAttributes(child) : child.innerHTML; let parsedAttributes = { value }; if (child.attributes.length) { - const childAttributes = Array.from(child.attributes).reduce((acc, { name, value }) => { + const childAttributes = Array.from(child.attributes).reduce>((acc, { name, value }) => { acc[name] = value; return acc; }, {}); @@ -99,7 +99,7 @@ class CardImporterService { // @TODO clean this up and normalize what i'm returning if (attributes[child.tagName]) { if (Array.isArray(attributes[child.tagName])) { - attributes[child.tagName].push(parsedAttributes) + attributes[child.tagName].push(parsedAttributes); } else { attributes[child.tagName] = [attributes[child.tagName], parsedAttributes]; } diff --git a/webclient/src/services/dexie/DexieDTOs/CardDTO.ts b/webclient/src/services/dexie/DexieDTOs/CardDTO.ts index 863c5107a..fdd31bf1c 100644 --- a/webclient/src/services/dexie/DexieDTOs/CardDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/CardDTO.ts @@ -15,6 +15,6 @@ export class CardDTO extends App.Card { static bulkAdd(cards: CardDTO[]): Promise { return dexieService.cards.bulkPut(cards); } -}; +} dexieService.cards.mapToClass(CardDTO); diff --git a/webclient/src/services/dexie/DexieDTOs/HostDTO.ts b/webclient/src/services/dexie/DexieDTOs/HostDTO.ts index e07440f64..9288701a6 100644 --- a/webclient/src/services/dexie/DexieDTOs/HostDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/HostDTO.ts @@ -24,9 +24,9 @@ export class HostDTO extends App.Host { return dexieService.hosts.bulkAdd(hosts); } - static delete(id: string): Promise { + static delete(id: number): Promise { return dexieService.hosts.delete(id); } -}; +} dexieService.hosts.mapToClass(HostDTO); diff --git a/webclient/src/services/dexie/DexieDTOs/SetDTO.ts b/webclient/src/services/dexie/DexieDTOs/SetDTO.ts index ac90e19b7..151692a4e 100644 --- a/webclient/src/services/dexie/DexieDTOs/SetDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/SetDTO.ts @@ -15,6 +15,6 @@ export class SetDTO extends App.Set { static bulkAdd(sets: SetDTO[]): Promise { return dexieService.sets.bulkPut(sets); } -}; +} dexieService.sets.mapToClass(SetDTO); diff --git a/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts b/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts index 666af5ef8..363b96050 100644 --- a/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts @@ -18,6 +18,6 @@ export class SettingDTO extends App.Setting { static get(user: string) { return dexieService.settings.where('user').equalsIgnoreCase(user).first(); } -}; +} dexieService.settings.mapToClass(SettingDTO); diff --git a/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts b/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts index 1321fc437..a41b76ee0 100644 --- a/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts @@ -15,6 +15,6 @@ export class TokenDTO extends App.Token { static bulkAdd(tokens: TokenDTO[]): Promise { return dexieService.tokens.bulkPut(tokens); } -}; +} dexieService.tokens.mapToClass(TokenDTO); diff --git a/webclient/src/services/dexie/DexieSchemas/v1.schema.ts b/webclient/src/services/dexie/DexieSchemas/v1.schema.ts index 32c29d2ed..238df02d5 100644 --- a/webclient/src/services/dexie/DexieSchemas/v1.schema.ts +++ b/webclient/src/services/dexie/DexieSchemas/v1.schema.ts @@ -16,4 +16,4 @@ export const schemaV1 = (db: Dexie) => { [Stores.TOKENS]: 'name.value', [Stores.HOSTS]: '++id', }); -} +}; diff --git a/webclient/src/store/actions/actionReducer.ts b/webclient/src/store/actions/actionReducer.ts index 03e32d1d8..e9e8ba85d 100644 --- a/webclient/src/store/actions/actionReducer.ts +++ b/webclient/src/store/actions/actionReducer.ts @@ -5,17 +5,17 @@ import { UnknownAction } from '@reduxjs/toolkit' - interface InitialState { - type: string | null - payload: unknown - meta: unknown - error: boolean - count: number - } +interface InitialState { + type: string | null + payload: unknown + meta: unknown + error: boolean + count: number +} /** - * Initial data. - */ + * Initial data. + */ const initialState: InitialState = { type: null, payload: null, @@ -25,12 +25,12 @@ const initialState: InitialState = { } /** - * Stores the most recent action so `useReduxEffect` can react to dispatches. - * - * Payloads are deep-cloned to prevent shared object references between this - * slice and the slice that owns the action. Without the clone, Immer mutations - * in the target slice are detected as mutations of the stale payload stored here. - */ + * Stores the most recent action so `useReduxEffect` can react to dispatches. + * + * Payloads are deep-cloned to prevent shared object references between this + * slice and the slice that owns the action. Without the clone, Immer mutations + * in the target slice are detected as mutations of the stale payload stored here. + */ export const actionReducer = ( state = initialState, action: UnknownAction, diff --git a/webclient/src/store/common/SortUtil.ts b/webclient/src/store/common/SortUtil.ts index a8eaf7512..c42b72926 100644 --- a/webclient/src/store/common/SortUtil.ts +++ b/webclient/src/store/common/SortUtil.ts @@ -22,12 +22,12 @@ export default class SortUtil { static sortByFields(arr: T[], sorts: App.SortBy[]) { if (arr.length) { + const fieldTypes = sorts.map(s => typeof SortUtil.resolveFieldChain(arr[0], s.field)); + arr.sort((a, b) => { for (let i = 0; i < sorts.length; i++) { const sortBy = sorts[i]; - const field = SortUtil.resolveFieldChain(arr[0], sortBy.field); - - const fieldType = typeof field; + const fieldType = fieldTypes[i]; if (fieldType === 'string') { const result = SortUtil.stringComparator(a, b, sortBy); @@ -47,13 +47,13 @@ export default class SortUtil { } return 0; - }) + }); } } static sortUsersByField(users: Data.ServerInfo_User[], sortBy: App.SortBy) { if (users.length) { - users.sort((a, b) => SortUtil.userComparator(a, b, sortBy)) + users.sort((a, b) => SortUtil.userComparator(a, b, sortBy)); } } @@ -89,18 +89,16 @@ export default class SortUtil { arr.sort((a, b) => SortUtil.stringComparator(a, b, sortBy)); } - private static userComparator(a: Data.ServerInfo_User, b: Data.ServerInfo_User, sortBy: App.SortBy, sortByUserLevel = true) { - if (sortByUserLevel) { - const adminSortBy = { - field: 'userLevel', - order: App.SortDirection.DESC - }; + private static userComparator(a: Data.ServerInfo_User, b: Data.ServerInfo_User, sortBy: App.SortBy) { + const adminSortBy = { + field: 'userLevel', + order: App.SortDirection.DESC + }; - const adminSorted = SortUtil.numberComparator(a, b, adminSortBy); + const adminSorted = SortUtil.numberComparator(a, b, adminSortBy); - if (adminSorted) { - return adminSorted; - } + if (adminSorted) { + return adminSorted; } const sorted = SortUtil.stringComparator(a, b, sortBy); diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index ecb20c917..349ee9fe0 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -84,7 +84,7 @@ function normalizePlayers(playerList: Data.ServerInfo_Player[]): { [playerId: nu byId[card.id] = card; } zones[zone.name] = { - name: zone.name, + name: zone.name as Enriched.ZoneNameValue, type: zone.type, withCoords: zone.withCoords, cardCount: zone.cardCount, diff --git a/webclient/src/store/rooms/rooms.reducer.ts b/webclient/src/store/rooms/rooms.reducer.ts index 31a961e72..f53580dad 100644 --- a/webclient/src/store/rooms/rooms.reducer.ts +++ b/webclient/src/store/rooms/rooms.reducer.ts @@ -3,7 +3,7 @@ import { App, Data, Enriched } from '@app/types'; import { mergeSetFields, normalizeGameObject, normalizeGametypeMap, normalizeRoomInfo, normalizeUserMessage } from '../common'; -import { GameFilters, RoomsState } from './rooms.interfaces' +import { GameFilters, RoomsState } from './rooms.interfaces'; import { DEFAULT_GAME_FILTERS } from './gameFilters'; export const MAX_ROOM_MESSAGES = 1000; diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index ea7f61d15..a086f1f4d 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -16,10 +16,10 @@ export interface ServerState { sortUsersBy: ServerStateSortUsersBy; messages: { [userName: string]: Data.Event_UserMessage[]; - } + }; userInfo: { [userName: string]: Data.ServerInfo_User; - } + }; notifications: Data.Event_NotifyUser[]; serverShutdown: Data.Event_ServerShutdown | null; banUser: string; @@ -60,5 +60,5 @@ export interface ServerStateLogs { } export interface ServerStateSortUsersBy extends App.SortBy { - field: App.UserSortField + field: App.UserSortField; } diff --git a/webclient/src/store/server/server.selectors.ts b/webclient/src/store/server/server.selectors.ts index 31dd08a16..4794f54c4 100644 --- a/webclient/src/store/server/server.selectors.ts +++ b/webclient/src/store/server/server.selectors.ts @@ -62,6 +62,9 @@ export const Selectors = { return (user.userLevel & mask) === mask; } ), + /** Fetched user profile info, keyed by username. Populated by Command_GetUserInfo responses. */ + getUserInfoByName: ({ server }: State, userName: string): Data.ServerInfo_User | undefined => + server.userInfo[userName], getLogs: ({ server }: State) => server.logs, getBackendDecks: ({ server }: State) => server.backendDecks, getDownloadedDeck: ({ server }: State) => server.downloadedDeck, diff --git a/webclient/src/types/cards.ts b/webclient/src/types/cards.ts index b28bae203..d6bbcf909 100644 --- a/webclient/src/types/cards.ts +++ b/webclient/src/types/cards.ts @@ -71,6 +71,13 @@ export class Set { type: string; } +/** + * Token fields are shaped `{ value: string }` because Cockatrice serves tokens + * as XML (tokens.xml), and each leaf preserves the XML element's text content + * alongside its attributes. The wrapper is not a DB artifact — it mirrors the + * `value` shape produced by CardImporterService's + * parseXmlAttributes. + */ export class Token { name: { value: string }; prop: { diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index 0b3a389d9..8b1eb1839 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -75,7 +75,7 @@ export const ZoneName = { export type ZoneNameValue = typeof ZoneName[keyof typeof ZoneName]; export interface ZoneEntry { - name: string; + name: ZoneNameValue; /** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */ type: number; withCoords: boolean; diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index 21cd46456..8088753b2 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -44,6 +44,11 @@ export class WebClient { onConnectionFailed: () => { this.response.session.connectionFailed(); }, + reconnect: { + maxAttempts: 5, + baseDelayMs: 1000, + maxDelayMs: 30000, + }, }); this.protobuf = new ProtobufService( @@ -63,12 +68,12 @@ export class WebClient { this.response.session.initialized(); } - public connect(target: ConnectTarget) { + public connect(target: ConnectTarget): void { this.response.session.connectionAttempted(); this.socket.connect(target); } - public testConnect(target: ConnectTarget) { + public testConnect(target: ConnectTarget): void { // A prior test connection still in flight when the user re-clicks would // otherwise leak the socket until its keepalive timeout. Close eagerly. if (this.testSocket) { @@ -107,15 +112,19 @@ export class WebClient { }; } - public disconnect() { + public disconnect(): void { this.socket.disconnect(); } - public updateStatus(status: StatusEnum) { + public updateStatus(status: StatusEnum): void { this.status = status; if (status === StatusEnum.DISCONNECTED) { this.protobuf.resetCommands(); } } + + public get isReconnecting(): boolean { + return this.status === StatusEnum.RECONNECTING; + } } diff --git a/webclient/src/websocket/commands/session/listRooms.ts b/webclient/src/websocket/commands/session/listRooms.ts index 25b0837f3..cfbcc7cb7 100644 --- a/webclient/src/websocket/commands/session/listRooms.ts +++ b/webclient/src/websocket/commands/session/listRooms.ts @@ -1,5 +1,6 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; + import { Command_ListRooms_ext, Command_ListRoomsSchema } from '@app/generated'; export function listRooms(): void { diff --git a/webclient/src/websocket/commands/session/message.ts b/webclient/src/websocket/commands/session/message.ts index 94afefc46..bdb4ac759 100644 --- a/webclient/src/websocket/commands/session/message.ts +++ b/webclient/src/websocket/commands/session/message.ts @@ -1,5 +1,6 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; + import { Command_Message_ext, Command_MessageSchema } from '@app/generated'; export function message(userName: string, message: string): void { diff --git a/webclient/src/websocket/commands/session/ping.ts b/webclient/src/websocket/commands/session/ping.ts index c0491a654..7ad16482f 100644 --- a/webclient/src/websocket/commands/session/ping.ts +++ b/webclient/src/websocket/commands/session/ping.ts @@ -1,5 +1,6 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; + import { Command_Ping_ext, Command_PingSchema } from '@app/generated'; export function ping(pingReceived: () => void): void { diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts index 40168cf0d..ad9d6cf5c 100644 --- a/webclient/src/websocket/events/game/gameClosed.ts +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -1,6 +1,8 @@ +import type { Event_GameClosed } from '@app/generated'; + import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function gameClosed(_data: {}, meta: GameEventMeta): void { +export function gameClosed(_data: Event_GameClosed, meta: GameEventMeta): void { WebClient.instance.response.game.gameClosed(meta.gameId); } diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts index da89fc34d..b1321cedd 100644 --- a/webclient/src/websocket/events/game/gameHostChanged.ts +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -1,3 +1,5 @@ +import type { Event_GameHostChanged } from '@app/generated'; + import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; @@ -5,6 +7,6 @@ import { WebClient } from '../../WebClient'; * Event_GameHostChanged carries no payload fields. * The new host is identified by GameEvent.player_id (meta.playerId). */ -export function gameHostChanged(_data: {}, meta: GameEventMeta): void { +export function gameHostChanged(_data: Event_GameHostChanged, meta: GameEventMeta): void { WebClient.instance.response.game.gameHostChanged(meta.gameId, meta.playerId); } diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts index ba7db0674..4a6a8c455 100644 --- a/webclient/src/websocket/events/game/kicked.ts +++ b/webclient/src/websocket/events/game/kicked.ts @@ -1,6 +1,8 @@ +import type { Event_Kicked } from '@app/generated'; + import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function kicked(_data: {}, meta: GameEventMeta): void { +export function kicked(_data: Event_Kicked, meta: GameEventMeta): void { WebClient.instance.response.game.kicked(meta.gameId); } diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 738a34ce7..94a84e843 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,6 +1,8 @@ +import type { Event_Leave } from '@app/generated'; + import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { +export function leaveGame(data: Event_Leave, meta: GameEventMeta): void { WebClient.instance.response.game.playerLeft(meta.gameId, meta.playerId, data.reason ?? 1); } diff --git a/webclient/src/websocket/events/room/index.ts b/webclient/src/websocket/events/room/index.ts index 2b27954d2..0456ea783 100644 --- a/webclient/src/websocket/events/room/index.ts +++ b/webclient/src/websocket/events/room/index.ts @@ -14,8 +14,8 @@ import { import { joinRoom } from './joinRoom'; import { leaveRoom } from './leaveRoom'; import { listGames } from './listGames'; -import { roomSay } from './roomSay'; import { removeMessages } from './removeMessages'; +import { roomSay } from './roomSay'; type RoomRegistryEntry = RegistryEntry; export type RoomExtensionRegistry = RoomRegistryEntry[]; diff --git a/webclient/src/websocket/events/session/index.ts b/webclient/src/websocket/events/session/index.ts index 847ea3246..7df1c46df 100644 --- a/webclient/src/websocket/events/session/index.ts +++ b/webclient/src/websocket/events/session/index.ts @@ -22,6 +22,7 @@ import { import { addToList } from './addToList'; import { connectionClosed } from './connectionClosed'; +import { gameJoined } from './gameJoined'; import { listRooms } from './listRooms'; import { notifyUser } from './notifyUser'; import { removeFromList } from './removeFromList'; @@ -33,7 +34,6 @@ import { serverShutdown } from './serverShutdown'; import { userJoined } from './userJoined'; import { userLeft } from './userLeft'; import { userMessage } from './userMessage'; -import { gameJoined } from './gameJoined'; type SessionRegistryEntry = RegistryEntry; export type SessionExtensionRegistry = SessionRegistryEntry[]; diff --git a/webclient/src/websocket/services/KeepAliveService.ts b/webclient/src/websocket/services/KeepAliveService.ts index 03f42d2a1..7eb38ea18 100644 --- a/webclient/src/websocket/services/KeepAliveService.ts +++ b/webclient/src/websocket/services/KeepAliveService.ts @@ -20,7 +20,7 @@ export class KeepAliveService { this.disconnected$.next(); } - // stop the ping loop if we"re disconnected + // stop the ping loop if we're disconnected if (!this.isOpen()) { this.endPingLoop(); return; diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 086c9cf41..be5e24fa6 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -224,5 +224,4 @@ export class ProtobufService { } } } - } diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index 83f1d6dc1..17f92f0fb 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -7,7 +7,7 @@ vi.mock('../config', () => ({ })); import { WebSocketService } from './WebSocketService'; -import type { WebSocketServiceConfig } from './WebSocketService'; +import type { WebSocketServiceConfig, ReconnectConfig } from './WebSocketService'; import { KeepAliveService } from './KeepAliveService'; import { StatusEnum } from '../types/StatusEnum'; @@ -204,6 +204,21 @@ describe('WebSocketService', () => { const data = new Uint8Array([1, 2, 3]); expect(() => service.send(data)).not.toThrow(); }); + + it('skips send when readyState is not OPEN', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const service = createConnectedService(); + // CONNECTING + mockInstance.readyState = 0; + const data = new Uint8Array([1, 2, 3]); + service.send(data); + expect(mockInstance.send).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[WebSocketService] send() skipped: socket not OPEN', + 0, + ); + warnSpy.mockRestore(); + }); }); describe('checkReadyState', () => { @@ -227,4 +242,86 @@ describe('WebSocketService', () => { }); }); + describe('connect (re-entry)', () => { + it('closes the prior socket when connect is called twice', () => { + const service = new WebSocketService(mockConfig); + service.connect({ host: 'h', port: '1' }, 'ws'); + const firstInstance = mockInstance; + service.connect({ host: 'h', port: '2' }, 'ws'); + expect(firstInstance.close).toHaveBeenCalled(); + }); + }); + + describe('reconnect', () => { + const reconnect: ReconnectConfig = { + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 1000, + }; + + function createReconnectService() { + const service = new WebSocketService({ ...mockConfig, reconnect }); + service.connect({ host: 'h', port: '1' }, 'ws'); + mockInstance.onopen(); + return service; + } + + it('emits RECONNECTING (not DISCONNECTED) on unexpected close when configured', () => { + createReconnectService(); + mockOnStatusChange.mockClear(); + mockInstance.onclose(); + const statuses = mockOnStatusChange.mock.calls.map(c => c[0]); + expect(statuses).toContain(StatusEnum.RECONNECTING); + expect(statuses).not.toContain(StatusEnum.DISCONNECTED); + }); + + it('does not reconnect after explicit disconnect()', () => { + const service = createReconnectService(); + mockOnStatusChange.mockClear(); + service.disconnect(); + // onclose fires with `intentionalDisconnect` already set; reconnect is + // suppressed and the status settles on DISCONNECTED. + mockInstance.onclose(); + const statuses = mockOnStatusChange.mock.calls.map(c => c[0]); + expect(statuses).not.toContain(StatusEnum.RECONNECTING); + expect(statuses).toContain(StatusEnum.DISCONNECTED); + }); + + it('gives up after maxAttempts and emits DISCONNECTED', () => { + const { instances } = installMockWebSocket(); + const service = new WebSocketService({ ...mockConfig, reconnect }); + service.connect({ host: 'h', port: '1' }, 'ws'); + // Flip hasEverOpened so reconnect is eligible, then drop. + instances[0].onopen(); + mockOnStatusChange.mockClear(); + + // Walk through maxAttempts failed reconnects (each socket never opens, + // so the attempt counter keeps climbing). One final close after the + // counter hits max emits DISCONNECTED. + for (let i = 0; i <= reconnect.maxAttempts; i += 1) { + instances[i].onclose(); + vi.advanceTimersByTime(reconnect.maxDelayMs); + } + + const statuses = mockOnStatusChange.mock.calls.map(c => c[0]); + expect(statuses).toContain(StatusEnum.DISCONNECTED); + void service; + }); + + it('resets attempt counter on successful open', () => { + createReconnectService(); + mockOnStatusChange.mockClear(); + // simulate a drop, advance timer to trigger reconnect attempt + mockInstance.onclose(); + vi.advanceTimersByTime(reconnect.maxDelayMs); + // manually open the new socket (simulated) + mockInstance.onopen(); + mockOnStatusChange.mockClear(); + // another drop — the description should start the attempt counter from 1 again + mockInstance.onclose(); + const firstReconnect = mockOnStatusChange.mock.calls.find(c => c[0] === StatusEnum.RECONNECTING); + expect(firstReconnect?.[1]).toMatch(/attempt 1\//); + }); + }); + }); diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index b3e082c22..86e81a9cb 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -1,26 +1,51 @@ -import { Subject } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { StatusEnum } from '../types/StatusEnum'; import { KeepAliveService } from './KeepAliveService'; import { CLIENT_OPTIONS } from '../config'; import type { ConnectTarget } from '../types/WebClientConfig'; +export interface ReconnectConfig { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; +} + export interface WebSocketServiceConfig { keepAliveFn: (pingReceived: () => void) => void; onStatusChange: (status: StatusEnum, description: string) => void; onConnectionFailed: () => void; + /** Opt-in automatic reconnect on unexpected socket close. */ + reconnect?: ReconnectConfig; } export class WebSocketService { - private socket: WebSocket; + private socket: WebSocket | null = null; private config: WebSocketServiceConfig; private keepAliveService: KeepAliveService; private hasReportedError = false; - public message$: Subject = new Subject(); + private readonly messageSubject = new Subject(); + public readonly message$: Observable = this.messageSubject.asObservable(); - private keepalive: number; + private keepalive: number = CLIENT_OPTIONS.keepalive; + + private lastTarget: ConnectTarget | null = null; + private lastProtocol: string | null = null; + + private intentionalDisconnect = false; + /** + * True while `connect()` is cycling a prior socket out to bring a fresh one up. + * Suppresses the `DISCONNECTED` status emission the orphan socket's onclose + * would otherwise fire — that would clobber the `connectionAttempted()` we + * just dispatched at the WebClient layer. + */ + private retiringForReconnect = false; + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + /** Flips true on the first successful `onopen`. Gates reconnect — we never retry a connection that never established. */ + private hasEverOpened = false; constructor(config: WebSocketServiceConfig) { this.config = config; @@ -37,16 +62,30 @@ export class WebSocketService { protocol = 'ws'; } - const { host, port } = target; + // Retire any prior socket cleanly. The `retiringForReconnect` flag both + // suppresses reconnect scheduling for the orphan socket AND suppresses the + // DISCONNECTED status emission that would otherwise reset + // `connectionAttemptMade` set by the caller a moment ago. + this.retiringForReconnect = true; + this.clearReconnectTimer(); + this.closeActiveSocket(); + this.retiringForReconnect = false; + + this.lastTarget = target; + this.lastProtocol = protocol; + this.intentionalDisconnect = false; + this.reconnectAttempts = 0; + this.hasEverOpened = false; this.keepalive = CLIENT_OPTIONS.keepalive; + const { host, port } = target; this.socket = this.createWebSocket(`${protocol}://${host}:${port}`); } public disconnect(): void { - if (this.socket) { - this.socket.close(); - } + this.intentionalDisconnect = true; + this.clearReconnectTimer(); + this.closeActiveSocket(); } public checkReadyState(state: number): boolean { @@ -57,7 +96,13 @@ export class WebSocketService { if (!this.socket) { return; } - this.socket.send(message as unknown as ArrayBufferView); + if (this.socket.readyState !== WebSocket.OPEN) { + // Match desktop's TCP-queued semantics conservatively: drop with a warn rather + // than throw. Upstream code treats send as fire-and-forget under these states. + console.warn('[WebSocketService] send() skipped: socket not OPEN', this.socket.readyState); + return; + } + this.socket.send(message as BufferSource); } private createWebSocket(url: string): WebSocket { @@ -65,10 +110,13 @@ export class WebSocketService { socket.binaryType = 'arraybuffer'; const connectionTimer = setTimeout(() => socket.close(), this.keepalive); + const clearConnectionTimer = (): void => clearTimeout(connectionTimer); socket.onopen = () => { - clearTimeout(connectionTimer); + this.hasEverOpened = true; + clearConnectionTimer(); this.hasReportedError = false; + this.reconnectAttempts = 0; this.config.onStatusChange(StatusEnum.CONNECTED, 'Connected'); this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: () => void) => { @@ -77,26 +125,101 @@ export class WebSocketService { }; socket.onclose = () => { + clearConnectionTimer(); + this.keepAliveService.endPingLoop(); + + if (this.shouldAttemptReconnect()) { + this.scheduleReconnect(); + return; + } + + // Orphan socket retired by a fresh connect() call — the new socket is + // already being wired up; don't fire a DISCONNECTED status that would + // race the just-dispatched connectionAttempted. + if (this.retiringForReconnect) { + this.hasReportedError = false; + return; + } + // @critical onerror + onclose both fire on failed connects; don't overwrite the richer error status. // See .github/instructions/webclient.instructions.md#websocket-lifecycle. if (!this.hasReportedError) { this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Closed'); } this.hasReportedError = false; - this.keepAliveService.endPingLoop(); }; socket.onerror = () => { + clearConnectionTimer(); this.hasReportedError = true; this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Failed'); this.config.onConnectionFailed(); }; socket.onmessage = (event: MessageEvent) => { - this.message$.next(event); - } + this.messageSubject.next(event); + }; return socket; } + private shouldAttemptReconnect(): boolean { + if (this.intentionalDisconnect) { + return false; + } + if (this.retiringForReconnect) { + return false; + } + // Suppress reconnect when the connection never established — onerror already + // reported DISCONNECTED and we don't want to thrash against a dead endpoint. + if (this.hasReportedError) { + return false; + } + // Only retry once we have proof the endpoint was reachable at least once. + // The initial connect path falls through to DISCONNECTED on failure. + if (!this.hasEverOpened) { + return false; + } + const cfg = this.config.reconnect; + if (!cfg || cfg.maxAttempts <= 0) { + return false; + } + return this.reconnectAttempts < cfg.maxAttempts; + } + + private scheduleReconnect(): void { + const cfg = this.config.reconnect!; + const attempt = this.reconnectAttempts; + const delay = Math.min(cfg.baseDelayMs * Math.pow(2, attempt), cfg.maxDelayMs); + this.reconnectAttempts += 1; + + this.config.onStatusChange( + StatusEnum.RECONNECTING, + `Reconnecting (attempt ${this.reconnectAttempts}/${cfg.maxAttempts})`, + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.intentionalDisconnect || !this.lastTarget || !this.lastProtocol) { + return; + } + const { host, port } = this.lastTarget; + this.socket = this.createWebSocket(`${this.lastProtocol}://${host}:${port}`); + }, delay); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private closeActiveSocket(): void { + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } + } diff --git a/webclient/src/websocket/types/StatusEnum.ts b/webclient/src/websocket/types/StatusEnum.ts index 179bdf675..1ae319a27 100644 --- a/webclient/src/websocket/types/StatusEnum.ts +++ b/webclient/src/websocket/types/StatusEnum.ts @@ -4,5 +4,8 @@ export enum StatusEnum { CONNECTED, LOGGING_IN, LOGGED_IN, - DISCONNECTING = 99 + /** Separated from sequential states to reserve room for future connection phases. */ + RECONNECTING = 50, + /** High sentinel value — marks the terminal "tearing down" state that must not collide with future states added above. */ + DISCONNECTING = 99, } diff --git a/webclient/src/websocket/utils/passwordHasher.ts b/webclient/src/websocket/utils/passwordHasher.ts index fdf8cb3ad..baf1d3b3d 100644 --- a/webclient/src/websocket/utils/passwordHasher.ts +++ b/webclient/src/websocket/utils/passwordHasher.ts @@ -15,7 +15,7 @@ export const hashPassword = (salt: string, password: string): string => { }; export const generateSalt = (): string => { - const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const bytes = new Uint8Array(SALT_LENGTH); crypto.getRandomValues(bytes); @@ -26,9 +26,9 @@ export const generateSalt = (): string => { } return salt; -} +}; -export const passwordSaltSupported = (serverOptions: number): number => { - // @critical Servatrice ServerOptions is a bitmask. See .github/instructions/webclient.instructions.md#protocol-quirks. - return serverOptions & Event_ServerIdentification_ServerOptions.SupportsPasswordHash; -} +export const passwordSaltSupported = (serverOptions: number): boolean => { + // Servatrice ServerOptions is a bitmask. See .github/instructions/webclient.instructions.md#protocol-quirks. + return (serverOptions & Event_ServerIdentification_ServerOptions.SupportsPasswordHash) !== 0; +}; diff --git a/webclient/src/websocket/utils/sanitizeHtml.util.ts b/webclient/src/websocket/utils/sanitizeHtml.util.ts index e5e240127..ab5911daa 100644 --- a/webclient/src/websocket/utils/sanitizeHtml.util.ts +++ b/webclient/src/websocket/utils/sanitizeHtml.util.ts @@ -15,6 +15,10 @@ export function sanitizeHtml(msg: string): string { return DOMPurify.sanitize(msg, { ALLOWED_TAGS: ['br', 'a', 'img', 'center', 'b', 'font'], ALLOWED_ATTR: ['href', 'color', 'rel', 'target', 'src', 'alt'], + // Load-bearing: DOMPurify applies ALLOWED_URI_REGEXP to *every* attribute + // it isn't explicitly told is URI-safe. `color` values like "red" don't + // match the regex and would be stripped. This whitelists `color` out of + // URI validation. Removing it breaks the sanitizer test. ADD_URI_SAFE_ATTR: ['color'], ALLOWED_URI_REGEXP: /^https?:/i, });