mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-05 18:01:56 +02:00
@@ -9,6 +9,7 @@
|
||||
"@fontsource/poppins": "^5.1.0",
|
||||
"@formatjs/intl-localematcher": "^0.2.32",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@react-hookz/deep-equal": "^3.0.3",
|
||||
"@react-three/drei": "^9.114.3",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@sentry/react": "^9.9.0",
|
||||
@@ -26,6 +27,7 @@
|
||||
"flatbuffers": "22.10.26",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"ip-num": "^1.5.1",
|
||||
"jotai": "^2.12.2",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -3,17 +3,17 @@ import { ClearMountingResetRequestT, RpcMessage } from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { BigButton } from './commons/BigButton';
|
||||
import { TrashIcon } from './commons/icon/TrashIcon';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { Quaternion } from 'three';
|
||||
import { QuaternionFromQuatT, similarQuaternions } from '@/maths/quaternion';
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { assignedTrackersAtom } from '@/store/app-store';
|
||||
|
||||
const _q = new Quaternion();
|
||||
|
||||
export function ClearMountingButton() {
|
||||
const { sendRPCPacket } = useWebsocketAPI();
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const trackerWithMounting = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { QuestionIcon } from './commons/icon/QuestionIcon';
|
||||
import { useBreakpoint, useIsTauri } from '@/hooks/breakpoint';
|
||||
import { GearIcon } from './commons/icon/GearIcon';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { TrackersStillOnModal } from './TrackersStillOnModal';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { listen, TauriEvent } from '@tauri-apps/api/event';
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
getCurrentWindow,
|
||||
UserAttentionType,
|
||||
} from '@tauri-apps/api/window';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function VersionTag() {
|
||||
return (
|
||||
@@ -63,8 +64,7 @@ export function TopBar({
|
||||
const isTauri = useIsTauri();
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const { config, setConfig, saveConfig } = useConfig();
|
||||
const version = useContext(VersionContext);
|
||||
const [localIp, setLocalIp] = useState<string | null>(null);
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
UnknownDeviceHandshakeNotificationT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useDebouncedEffect } from '@/hooks/timeout';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { useAtom } from 'jotai';
|
||||
import { ignoredTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function UnknownDeviceModal() {
|
||||
const { l10n } = useLocalization();
|
||||
const [open, setOpen] = useState(0);
|
||||
const { pathname } = useLocation();
|
||||
const { state, dispatch } = useAppContext();
|
||||
const [ignoredTrackers, setIgnoredTracker] = useAtom(ignoredTrackersAtom);
|
||||
const [currentTracker, setCurrentTracker] = useState<string | null>(null);
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
|
||||
@@ -28,7 +29,7 @@ export function UnknownDeviceModal() {
|
||||
['/onboarding/connect-trackers', '/settings/firmware-tool'].includes(
|
||||
pathname
|
||||
) ||
|
||||
state.ignoredTrackers.has(macAddress as string) ||
|
||||
(macAddress && ignoredTrackers.has(macAddress?.toString())) ||
|
||||
(currentTracker !== null && currentTracker !== macAddress)
|
||||
)
|
||||
return;
|
||||
@@ -91,9 +92,10 @@ export function UnknownDeviceModal() {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'ignoreTracker',
|
||||
value: currentTracker as string,
|
||||
setIgnoredTracker((state) => {
|
||||
if (!currentTracker) throw 'should have a tracker';
|
||||
state.add(currentTracker);
|
||||
return state;
|
||||
});
|
||||
closeModal();
|
||||
}}
|
||||
|
||||
@@ -17,23 +17,42 @@ import {
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { parseStatusToLocale, useStatusContext } from '@/hooks/status-system';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { ClearMountingButton } from './ClearMountingButton';
|
||||
import { ToggleableSkeletonVisualizerWidget } from './widgets/SkeletonVisualizerWidget';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
|
||||
function UnprioritizedStatuses() {
|
||||
const { l10n } = useLocalization();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
const { statuses } = useStatusContext();
|
||||
const unprioritizedStatuses = useMemo(
|
||||
() => Object.values(statuses).filter((status) => !status.prioritized),
|
||||
[statuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3 mb-2">
|
||||
{unprioritizedStatuses.map((status) => (
|
||||
<Localized
|
||||
id={`status_system-${StatusData[status.dataType]}`}
|
||||
vars={parseStatusToLocale(status, trackers, l10n)}
|
||||
key={status.id}
|
||||
>
|
||||
<TipBox whitespace={false} hideIcon>
|
||||
{`Warning, you should fix ${StatusData[status.dataType]}`}
|
||||
</TipBox>
|
||||
</Localized>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetsComponent() {
|
||||
const { config } = useConfig();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [driftCompensationEnabled, setDriftCompensationEnabled] =
|
||||
useState(false);
|
||||
const { trackers } = useAppContext();
|
||||
const { statuses } = useStatusContext();
|
||||
const { l10n } = useLocalization();
|
||||
const unprioritizedStatuses = useMemo(
|
||||
() => Object.values(statuses).filter((status) => !status.prioritized),
|
||||
[statuses]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT());
|
||||
}, []);
|
||||
@@ -62,19 +81,7 @@ export function WidgetsComponent() {
|
||||
<div className="mb-2">
|
||||
<ToggleableSkeletonVisualizerWidget height={400} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3 mb-2">
|
||||
{unprioritizedStatuses.map((status) => (
|
||||
<Localized
|
||||
id={`status_system-${StatusData[status.dataType]}`}
|
||||
vars={parseStatusToLocale(status, trackers, l10n)}
|
||||
key={status.id}
|
||||
>
|
||||
<TipBox whitespace={false} hideIcon={true}>
|
||||
{`Warning, you should fix ${StatusData[status.dataType]}`}
|
||||
</TipBox>
|
||||
</Localized>
|
||||
))}
|
||||
</div>
|
||||
<UnprioritizedStatuses></UnprioritizedStatuses>
|
||||
{config?.debug && (
|
||||
<div className="w-full">
|
||||
<DeveloperModeWidget></DeveloperModeWidget>
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { BodyPart, TrackerDataT } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useTracker } from '@/hooks/tracker';
|
||||
import { PersonFrontIcon } from './PersonFrontIcon';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
interface SlotDot {
|
||||
id: string;
|
||||
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
TrackerStatus,
|
||||
} from 'solarxr-protocol';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { Input } from '@/components/commons/Input';
|
||||
import { Dropdown } from '@/components/commons/Dropdown';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { DeviceCardControl } from './DeviceCard';
|
||||
import { getTrackerName } from '@/hooks/tracker';
|
||||
import { ObjectSchema, object, string } from 'yup';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { devicesAtom } from '@/store/app-store';
|
||||
|
||||
interface FlashingMethodForm {
|
||||
flashingMethod?: string;
|
||||
@@ -191,10 +192,10 @@ function OTADevicesList({
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { selectDevices, newConfig } = useFirmwareTool();
|
||||
const { state } = useAppContext();
|
||||
const allDevices = useAtomValue(devicesAtom);
|
||||
|
||||
const devices =
|
||||
state.datafeed?.devices.filter(({ trackers, hardwareInfo }) => {
|
||||
allDevices.filter(({ trackers, hardwareInfo }) => {
|
||||
// We make sure the device is not one of these types
|
||||
if (
|
||||
hardwareInfo?.officialBoardType === BoardType.SLIMEVR_LEGACY ||
|
||||
|
||||
@@ -33,6 +33,8 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { object } from 'yup';
|
||||
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
|
||||
import { A } from '@/components/commons/A';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { devicesAtom } from '@/store/app-store';
|
||||
|
||||
export function checkForUpdate(
|
||||
currentFirmwareRelease: FirmwareRelease,
|
||||
@@ -104,10 +106,9 @@ const StatusList = ({ status }: { status: Record<string, UpdateStatus> }) => {
|
||||
const val = status[id];
|
||||
|
||||
if (!val) throw new Error('there should always be a val');
|
||||
const { state } = useAppContext();
|
||||
const device = state.datafeed?.devices.find(
|
||||
({ id: dId }) => id === dId?.id.toString()
|
||||
);
|
||||
const devices = useAtomValue(devicesAtom);
|
||||
|
||||
const device = devices.find(({ id: dId }) => id === dId?.id.toString());
|
||||
|
||||
return (
|
||||
<DeviceCardControl
|
||||
@@ -132,11 +133,13 @@ export function FirmwareUpdate() {
|
||||
const { l10n } = useLocalization();
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const pendingDevicesRef = useRef<SelectedDevice[]>([]);
|
||||
const { state, currentFirmwareRelease } = useAppContext();
|
||||
const { currentFirmwareRelease } = useAppContext();
|
||||
const [status, setStatus] = useState<Record<string, UpdateStatus>>({});
|
||||
|
||||
const allDevices = useAtomValue(devicesAtom);
|
||||
|
||||
const devices =
|
||||
state.datafeed?.devices.filter(
|
||||
allDevices.filter(
|
||||
(device) =>
|
||||
device.trackers.length > 0 &&
|
||||
currentFirmwareRelease &&
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Localized, useLocalization } from '@fluent/react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { StatusData, TrackerDataT } from 'solarxr-protocol';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { TrackerCard } from '@/components/tracker/TrackerCard';
|
||||
import { TrackersTable } from '@/components/tracker/TrackersTable';
|
||||
@@ -15,6 +14,8 @@ import { useMemo } from 'react';
|
||||
import { WarningBox } from '@/components/commons/TipBox';
|
||||
import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
import { useVRCConfig } from '@/hooks/vrc-config';
|
||||
|
||||
const DONT_REPEAT_STATUSES = [StatusData.StatusTrackerReset];
|
||||
@@ -22,7 +23,7 @@ const DONT_REPEAT_STATUSES = [StatusData.StatusTrackerReset];
|
||||
export function Home() {
|
||||
const { l10n } = useLocalization();
|
||||
const { config } = useConfig();
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
const { statuses } = useStatusContext();
|
||||
const { invalidConfig } = useVRCConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { AssignMode } from '@/hooks/config';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyInteractions } from '@/components/commons/BodyInteractions';
|
||||
import { TrackerPartCard } from '@/components/tracker/TrackerPartCard';
|
||||
import { BodyPartError } from './pages/trackers-assign/TrackerAssignment';
|
||||
import { SIDES } from '@/components/commons/PersonFrontIcon';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { assignedTrackersAtom, FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
export const ARMS_PARTS = new Set([
|
||||
BodyPart.LEFT_UPPER_ARM,
|
||||
@@ -112,9 +112,7 @@ export function BodyAssignment({
|
||||
width?: number;
|
||||
dotSize?: number;
|
||||
}) {
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const trackerPartGrouped = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -8,11 +8,12 @@ import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
|
||||
import { useCountdown } from '@/hooks/countdown';
|
||||
import classNames from 'classnames';
|
||||
import { TaybolIcon } from '@/components/commons/icon/TaybolIcon';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import { averageVector, Vector3FromVec3fT } from '@/maths/vector3';
|
||||
import { Vector3 } from 'three';
|
||||
import { useTimeout } from '@/hooks/timeout';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export enum CalibrationStatus {
|
||||
SUCCESS,
|
||||
@@ -36,8 +37,7 @@ export function CalibrationTutorialPage() {
|
||||
onCountdownEnd: () => setCalibrationStatus(CalibrationStatus.SUCCESS),
|
||||
});
|
||||
useTimeout(() => setSkipButton(true), 10000);
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const restCalibrationTrackers =
|
||||
useRestCalibrationTrackers(connectedIMUTrackers);
|
||||
const [rested, setRested] = useState(false);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
WifiProvisioningStatusResponseT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { ArrowLink } from '@/components/commons/ArrowLink';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
@@ -21,6 +20,8 @@ import { Typography } from '@/components/commons/Typography';
|
||||
import { TrackerCard } from '@/components/tracker/TrackerCard';
|
||||
import { useIsRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import './ConnectTracker.scss';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
const statusLabelMap = {
|
||||
[WifiProvisioningStatus.NONE]:
|
||||
@@ -57,7 +58,7 @@ const statusProgressMap = {
|
||||
|
||||
export function ConnectTrackersPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const navigate = useNavigate();
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
@@ -66,8 +67,6 @@ export function ConnectTrackersPage() {
|
||||
|
||||
applyProgress(0.4);
|
||||
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
|
||||
const bnoExists = useIsRestCalibrationTrackers(connectedIMUTrackers);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,24 +12,24 @@ import {
|
||||
SettingsRequestT,
|
||||
SettingsResponseT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyDisplay } from '@/components/commons/BodyDisplay';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import classNames from 'classnames';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { log } from '@/utils/logging';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { assignedTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function ResetTutorialPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress } = useOnboarding();
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [curIndex, setCurIndex] = useState(0);
|
||||
const [tapSettings, setTapSettings] = useState<number[]>([]);
|
||||
applyProgress(0.8);
|
||||
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const highestTorsoTracker = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -5,15 +5,15 @@ import { Button } from '@/components/commons/Button';
|
||||
import { Input } from '@/components/commons/Input';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import classNames from 'classnames';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useIsRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function WifiCredsPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const { control, handleSubmit, submitWifiCreds, formState } = useWifiForm();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
|
||||
applyProgress(0.2);
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ import { useLocalization } from '@fluent/react';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useIsRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import { StickerSlime } from './StickerSlime';
|
||||
import { TrackerArrow } from './TrackerArrow';
|
||||
import { ExtensionArrow } from './ExtensionArrow';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function AssignmentTutorialPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress } = useOnboarding();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const isRestCalibration = useIsRestCalibrationTrackers(connectedIMUTrackers);
|
||||
|
||||
applyProgress(0.46);
|
||||
|
||||
@@ -22,9 +22,10 @@ import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { error } from '@/utils/logging';
|
||||
import classNames from 'classnames';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { RulerIcon } from '@/components/commons/icon/RulerIcon';
|
||||
import { Tooltip } from '@/components/commons/Tooltip';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { computedTrackersAtom } from '@/store/app-store';
|
||||
import { RulerIcon } from '@/components/commons/icon/RulerIcon';
|
||||
import { PercentIcon } from '@/components/commons/icon/PercentIcon';
|
||||
import { UploadFileIcon } from '@/components/commons/icon/UploadFileIcon';
|
||||
import { FullResetIcon } from '@/components/commons/icon/ResetIcon';
|
||||
@@ -320,7 +321,7 @@ function PreciseToggle({ control }: { control: ManualProportionControls }) {
|
||||
function ButtonsControl({ control }: { control: ManualProportionControls }) {
|
||||
const { state } = useOnboarding();
|
||||
const nav = useNavigate();
|
||||
const { computedTrackers } = useAppContext();
|
||||
const computedTrackers = useAtomValue(computedTrackersAtom);
|
||||
const { sendRPCPacket } = useWebsocketAPI();
|
||||
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
@@ -8,19 +8,20 @@ import { CheckFloorHeightStep } from './autobone-steps/CheckFloorHeight';
|
||||
import { ResetProportionsStep } from './scaled-steps/ResetProportions';
|
||||
import { DoneStep } from './scaled-steps/Done';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMemo } from 'react';
|
||||
import { ManualHeightStep } from './scaled-steps/ManualHeightStep';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { WarningBox } from '@/components/commons/TipBox';
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
|
||||
export function ScaledProportionsPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const heightContext = useProvideHeightContext();
|
||||
const navigate = useNavigate();
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
|
||||
const { hasHmd, hasHandControllers } = useMemo(() => {
|
||||
const hasHmd = trackers.some(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyDisplay } from '@/components/commons/BodyDisplay';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { TipBox } from '@/components/commons/TipBox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function PutTrackersOnStep({
|
||||
nextStep,
|
||||
@@ -15,7 +16,7 @@ export function PutTrackersOnStep({
|
||||
}) {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AssignTrackerRequestT, BodyPart, RpcMessage } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import {
|
||||
MountingOrientationDegreesToQuatT,
|
||||
@@ -18,6 +16,8 @@ import { useLocalization } from '@fluent/react';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { Quaternion } from 'three';
|
||||
import { AssignMode, defaultConfig, useConfig } from '@/hooks/config';
|
||||
import { assignedTrackersAtom, FlatDeviceTracker } from '@/store/app-store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
export function ManualMountingPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
@@ -30,8 +30,7 @@ export function ManualMountingPage() {
|
||||
|
||||
applyProgress(0.7);
|
||||
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const trackerPartGrouped = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyDisplay } from '@/components/commons/BodyDisplay';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { TipBox } from '@/components/commons/TipBox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function PutTrackersOnStep({
|
||||
nextStep,
|
||||
@@ -14,7 +15,7 @@ export function PutTrackersOnStep({
|
||||
variant: 'alone' | 'onboarding';
|
||||
}) {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
const { l10n } = useLocalization();
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Typography } from '@/components/commons/Typography';
|
||||
import { AssignMode, defaultConfig, useConfig } from '@/hooks/config';
|
||||
import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import { Dropdown } from '@/components/commons/Dropdown';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
// Ordered collection of assign modes with the number of IMU trackers
|
||||
const ASSIGN_MODE_OPTIONS = [
|
||||
@@ -26,8 +27,7 @@ export function TrackerAssignOptions({
|
||||
variant: 'radio' | 'dropdown';
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers().length;
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
|
||||
const { config, setConfig } = useConfig();
|
||||
const { control, watch, setValue } = useForm<{
|
||||
@@ -44,11 +44,11 @@ export function TrackerAssignOptions({
|
||||
}, [assignMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectedIMUTrackers <= ASSIGN_MODE_OPTIONS[assignMode]) return;
|
||||
if (connectedIMUTrackers.length <= ASSIGN_MODE_OPTIONS[assignMode]) return;
|
||||
|
||||
const selectedAssignMode =
|
||||
(Object.entries(ASSIGN_MODE_OPTIONS).find(
|
||||
([_, count]) => count >= connectedIMUTrackers
|
||||
([_, count]) => count >= connectedIMUTrackers.length
|
||||
)?.[0] as AssignMode) ?? AssignMode.All;
|
||||
|
||||
if (assignMode !== selectedAssignMode) {
|
||||
@@ -114,7 +114,9 @@ export function TrackerAssignOptions({
|
||||
name="assignMode"
|
||||
control={control}
|
||||
value={mode}
|
||||
disabled={connectedIMUTrackers > trackersCount && mode !== AssignMode.All}
|
||||
disabled={
|
||||
connectedIMUTrackers.length > trackersCount && mode !== AssignMode.All
|
||||
}
|
||||
className="hidden"
|
||||
>
|
||||
<div className="flex flex-row md:gap-4 gap-2">
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
ChangeSettingsRequestT,
|
||||
TapDetectionSetupNotificationT,
|
||||
} from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useChokerWarning } from '@/hooks/choker-warning';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { CheckBox } from '@/components/commons/Checkbox';
|
||||
@@ -34,6 +32,12 @@ import { defaultConfig, useConfig } from '@/hooks/config';
|
||||
import { playTapSetupSound } from '@/sounds/sounds';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { TrackerAssignOptions } from './TrackerAssignOptions';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
assignedTrackersAtom,
|
||||
FlatDeviceTracker,
|
||||
flatTrackersAtom,
|
||||
} from '@/store/app-store';
|
||||
|
||||
export type BodyPartError = {
|
||||
label: string | undefined;
|
||||
@@ -51,7 +55,6 @@ export function TrackersAssignPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { config, setConfig } = useConfig();
|
||||
const { useAssignedTrackers, trackers } = useTrackers();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const defaultValues = {
|
||||
@@ -62,7 +65,10 @@ export function TrackersAssignPage() {
|
||||
}>({ defaultValues });
|
||||
const { mirrorView } = watch();
|
||||
const [selectedRole, setSelectRole] = useState<BodyPart>(BodyPart.NONE);
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig({ mirrorView });
|
||||
}, [mirrorView]);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
import ReactModal from 'react-modal';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { TipBox } from '@/components/commons/TipBox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { TrackerCard } from '@/components/tracker/TrackerCard';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
assignedTrackersAtom,
|
||||
FlatDeviceTracker,
|
||||
unassignedTrackersAtom,
|
||||
} from '@/store/app-store';
|
||||
|
||||
export function TrackerSelectionMenu({
|
||||
isOpen = true,
|
||||
@@ -21,10 +25,9 @@ export function TrackerSelectionMenu({
|
||||
onTrackerSelected: (tracker: FlatDeviceTracker | null) => void;
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { useAssignedTrackers, useUnassignedTrackers } = useTrackers();
|
||||
|
||||
const unassignedTrackers = useUnassignedTrackers();
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const unassignedTrackers = useAtomValue(unassignedTrackersAtom);
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import classNames from 'classnames';
|
||||
import { MouseEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { BodyPart, TrackerDataT } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useTracker } from '@/hooks/tracker';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { WarningIcon } from '@/components/commons/icon/WarningIcon';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
function Tracker({
|
||||
tracker,
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useAppContext } from '@/hooks/app';
|
||||
import { MagnetometerToggleSetting } from '@/components/settings/pages/MagnetometerToggleSetting';
|
||||
import semver from 'semver';
|
||||
import { checkForUpdate } from '@/components/firmware-update/FirmwareUpdate';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { ignoredTrackersAtom } from '@/store/app-store';
|
||||
|
||||
const rotationsLabels: [Quaternion, string][] = [
|
||||
[rotationToQuatMap.BACK, 'tracker-rotation-back'],
|
||||
@@ -72,7 +74,7 @@ export function TrackerSettingsPage() {
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
});
|
||||
const { dispatch } = useAppContext();
|
||||
const setIgnoredTracker = useSetAtom(ignoredTrackersAtom);
|
||||
const { trackerName, allowDriftCompensation } = watch();
|
||||
|
||||
const tracker = useTrackerFromId(trackernum, deviceid);
|
||||
@@ -534,7 +536,10 @@ export function TrackerSettingsPage() {
|
||||
RpcMessage.ForgetDeviceRequest,
|
||||
new ForgetDeviceRequestT(macAddress)
|
||||
);
|
||||
dispatch({ type: 'ignoreTracker', value: macAddress });
|
||||
setIgnoredTracker((state) => {
|
||||
state.add(macAddress);
|
||||
return state;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{l10n.getString('tracker-settings-forget-label')}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TrackerIdT,
|
||||
TrackerStatus as TrackerStatusEnum,
|
||||
} from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { useTracker } from '@/hooks/tracker';
|
||||
import { BodyPartIcon } from '@/components/commons/BodyPartIcon';
|
||||
@@ -17,6 +16,7 @@ import { TrackerBattery } from './TrackerBattery';
|
||||
import { TrackerStatus } from './TrackerStatus';
|
||||
import { TrackerWifi } from './TrackerWifi';
|
||||
import { trackerStatusRelated, useStatusContext } from '@/hooks/status-system';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
enum DisplayColumn {
|
||||
NAME,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Canvas, Object3DNode, extend, useThree } from '@react-three/fiber';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { Bone } from 'three';
|
||||
import { useMemo, useEffect, useRef, useState } from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import {
|
||||
OrbitControls,
|
||||
OrthographicCamera,
|
||||
@@ -20,6 +19,8 @@ import { Button } from '@/components/commons/Button';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { bonesAtom } from '@/store/app-store';
|
||||
|
||||
extend({ BasedSkeletonHelper });
|
||||
|
||||
@@ -135,22 +136,20 @@ export function ToggleableSkeletonVisualizerWidget({
|
||||
}
|
||||
|
||||
export function SkeletonVisualizerWidget() {
|
||||
const { bones: _bones } = useAppContext();
|
||||
const _bones = useAtomValue(bonesAtom);
|
||||
|
||||
const { l10n } = useLocalization();
|
||||
const bones = useMemo(
|
||||
() => new Map(_bones.map((b) => [b.bodyPart, b])),
|
||||
[JSON.stringify(_bones)]
|
||||
const bones = useMemo(() => {
|
||||
return new Map(_bones.map((b) => [b.bodyPart, b]));
|
||||
}, [_bones]);
|
||||
|
||||
const skeleton = useMemo(
|
||||
() => createChildren(bones, BoneKind.root),
|
||||
[bones.size]
|
||||
);
|
||||
|
||||
const skeleton = useRef<Bone[]>();
|
||||
|
||||
useEffect(() => {
|
||||
skeleton.current = createChildren(bones, BoneKind.root);
|
||||
}, [bones.size]);
|
||||
|
||||
useEffect(() => {
|
||||
skeleton.current?.forEach(
|
||||
skeleton.forEach(
|
||||
(bone) => bone instanceof BoneKind && bone.updateData(bones)
|
||||
);
|
||||
}, [bones]);
|
||||
@@ -166,15 +165,13 @@ export function SkeletonVisualizerWidget() {
|
||||
return (yLength as BoneT[]).reduce((prev, cur) => prev + cur.boneLength, 0);
|
||||
}, [bones]);
|
||||
|
||||
const bonesInitialized = bones.size > 0;
|
||||
|
||||
const targetCamera = useMemo(() => {
|
||||
const hmd = bones.get(BodyPart.HEAD);
|
||||
if (hmd?.headPositionG?.y && hmd.headPositionG.y > 0) {
|
||||
return hmd.headPositionG.y / 2;
|
||||
}
|
||||
return heightOffset / 2;
|
||||
}, [bonesInitialized]);
|
||||
}, [bones]);
|
||||
|
||||
const yawReset = useMemo(() => {
|
||||
const hmd = bones.get(BodyPart.HEAD);
|
||||
@@ -190,14 +187,14 @@ export function SkeletonVisualizerWidget() {
|
||||
new THREE.Vector3(quat.x, quat.y, quat.z).dot(VEC_Y) / VEC_Y.lengthSq()
|
||||
);
|
||||
return new THREE.Quaternion(vec.x, vec.y, vec.z, quat.w).normalize();
|
||||
}, [bonesInitialized]);
|
||||
}, [bones.size]);
|
||||
|
||||
const scale = useMemo(
|
||||
() => Math.max(1.8, heightOffset) / 1.8,
|
||||
[heightOffset]
|
||||
);
|
||||
|
||||
if (!skeleton.current) return <></>;
|
||||
if (!skeleton) return <></>;
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
@@ -209,9 +206,9 @@ export function SkeletonVisualizerWidget() {
|
||||
<Canvas className={classNames('container mx-auto')}>
|
||||
<gridHelper args={[10, 50, GROUND_COLOR, GROUND_COLOR]} />
|
||||
<group position={[0, heightOffset, 0]} quaternion={yawReset}>
|
||||
<SkeletonHelper object={skeleton.current[0]}></SkeletonHelper>
|
||||
<SkeletonHelper object={skeleton[0]}></SkeletonHelper>
|
||||
</group>
|
||||
<primitive object={skeleton.current[0]} />
|
||||
<primitive object={skeleton[0]} />
|
||||
<PerspectiveCamera
|
||||
makeDefault
|
||||
position={[3, 2.5, -3]}
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
Reducer,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
BoneT,
|
||||
DataFeedMessage,
|
||||
DataFeedUpdateT,
|
||||
DeviceDataT,
|
||||
ResetResponseT,
|
||||
ResetStatus,
|
||||
ResetType,
|
||||
RpcMessage,
|
||||
StartDataFeedT,
|
||||
TrackerDataT,
|
||||
} from 'solarxr-protocol';
|
||||
import { playSoundOnResetEnded, playSoundOnResetStarted } from '@/sounds/sounds';
|
||||
import { useConfig } from './config';
|
||||
@@ -26,6 +14,8 @@ import { useDataFeedConfig } from './datafeed-config';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { error } from '@/utils/logging';
|
||||
import { cacheWrap } from './cache';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { datafeedAtom, devicesAtom } from '@/store/app-store';
|
||||
import { updateSentryContext } from '@/utils/sentry';
|
||||
|
||||
export interface FirmwareRelease {
|
||||
@@ -35,41 +25,8 @@ export interface FirmwareRelease {
|
||||
firmwareFile: string;
|
||||
}
|
||||
|
||||
export interface FlatDeviceTracker {
|
||||
device?: DeviceDataT;
|
||||
tracker: TrackerDataT;
|
||||
}
|
||||
|
||||
export type AppStateAction =
|
||||
| { type: 'datafeed'; value: DataFeedUpdateT }
|
||||
| { type: 'ignoreTracker'; value: string };
|
||||
|
||||
export interface AppState {
|
||||
datafeed?: DataFeedUpdateT;
|
||||
ignoredTrackers: Set<string>;
|
||||
}
|
||||
|
||||
export interface AppContext {
|
||||
currentFirmwareRelease: FirmwareRelease | null;
|
||||
state: AppState;
|
||||
trackers: FlatDeviceTracker[];
|
||||
dispatch: Dispatch<AppStateAction>;
|
||||
bones: BoneT[];
|
||||
computedTrackers: FlatDeviceTracker[];
|
||||
}
|
||||
|
||||
export function reducer(state: AppState, action: AppStateAction) {
|
||||
switch (action.type) {
|
||||
case 'datafeed':
|
||||
return { ...state, datafeed: action.value };
|
||||
case 'ignoreTracker':
|
||||
return {
|
||||
...state,
|
||||
ignoredTrackers: new Set([...state.ignoredTrackers, action.value]),
|
||||
};
|
||||
default:
|
||||
throw new Error(`unhandled state action ${(action as AppStateAction).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function useProvideAppContext(): AppContext {
|
||||
@@ -77,10 +34,9 @@ export function useProvideAppContext(): AppContext {
|
||||
useWebsocketAPI();
|
||||
const { config } = useConfig();
|
||||
const { dataFeedConfig } = useDataFeedConfig();
|
||||
const [state, dispatch] = useReducer<Reducer<AppState, AppStateAction>>(reducer, {
|
||||
datafeed: new DataFeedUpdateT(),
|
||||
ignoredTrackers: new Set(),
|
||||
});
|
||||
const setDatafeed = useSetAtom(datafeedAtom);
|
||||
const devices = useAtomValue(devicesAtom);
|
||||
|
||||
const [currentFirmwareRelease, setCurrentFirmwareRelease] =
|
||||
useState<FirmwareRelease | null>(null);
|
||||
|
||||
@@ -92,32 +48,13 @@ export function useProvideAppContext(): AppContext {
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
const trackers = useMemo(
|
||||
() =>
|
||||
(state.datafeed?.devices || []).reduce<FlatDeviceTracker[]>(
|
||||
(curr, device) => [
|
||||
...curr,
|
||||
...device.trackers.map((tracker) => ({ tracker, device })),
|
||||
],
|
||||
[]
|
||||
),
|
||||
[state]
|
||||
);
|
||||
|
||||
const computedTrackers: FlatDeviceTracker[] = useMemo(
|
||||
() => (state.datafeed?.syntheticTrackers || []).map((tracker) => ({ tracker })),
|
||||
[state]
|
||||
);
|
||||
|
||||
const bones = useMemo(() => state.datafeed?.bones || [], [state]);
|
||||
|
||||
useDataFeedPacket(DataFeedMessage.DataFeedUpdate, (packet: DataFeedUpdateT) => {
|
||||
dispatch({ type: 'datafeed', value: packet });
|
||||
setDatafeed(packet);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateSentryContext(state);
|
||||
}, [state.datafeed?.devices]);
|
||||
updateSentryContext(devices);
|
||||
}, [devices]);
|
||||
|
||||
useRPCPacket(RpcMessage.ResetResponse, ({ status, resetType }: ResetResponseT) => {
|
||||
if (!config?.feedbackSound) return;
|
||||
@@ -187,11 +124,6 @@ export function useProvideAppContext(): AppContext {
|
||||
|
||||
return {
|
||||
currentFirmwareRelease,
|
||||
state,
|
||||
trackers,
|
||||
dispatch,
|
||||
bones,
|
||||
computedTrackers,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useConfig } from './config';
|
||||
import { useInterval } from './timeout';
|
||||
import { useTrackers } from './tracker';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { warn } from '@/utils/logging';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
import { getDefaultStore } from 'jotai';
|
||||
|
||||
export function useDiscordPresence() {
|
||||
const { config } = useConfig();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const { l10n } = useLocalization();
|
||||
const imuTrackers = useConnectedIMUTrackers();
|
||||
|
||||
const updatePresence = useCallback(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (await checkDiscordClient()) {
|
||||
// If discord client exists, try updating presence
|
||||
await updateDiscordPresence({
|
||||
details: l10n.getString(
|
||||
'settings-general-interface-discord_presence-message',
|
||||
{ amount: imuTrackers.length }
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// else, try creating a discord client
|
||||
await createDiscordClient();
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`failed to update presence, error: ${e}`);
|
||||
}
|
||||
})();
|
||||
}, [imuTrackers.length, l10n]);
|
||||
|
||||
// Update presence every 6.9 seconds
|
||||
useInterval(updatePresence, config?.discordPresence ? 6900 : null);
|
||||
useInterval(
|
||||
() => {
|
||||
(async () => {
|
||||
try {
|
||||
// Better to do this instead of useAtomValue as we are doing polling with the interval
|
||||
// useAtomValue can trigger re render of the dom and this hook is top level, so this
|
||||
// would be really bad
|
||||
const imuTrackers = getDefaultStore().get(connectedIMUTrackersAtom);
|
||||
if (await checkDiscordClient()) {
|
||||
// If discord client exists, try updating presence
|
||||
await updateDiscordPresence({
|
||||
details: l10n.getString(
|
||||
'settings-general-interface-discord_presence-message',
|
||||
{ amount: imuTrackers.length }
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// else, try creating a discord client
|
||||
await createDiscordClient();
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`failed to update presence, error: ${e}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
config?.discordPresence ? 6900 : null
|
||||
);
|
||||
|
||||
// Clear presence on config being disabled
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
import { useMemo } from 'react';
|
||||
import { FlatDeviceTracker } from './app';
|
||||
|
||||
const IGNORED_BOARDS = new Set(['Sony Mocopi', 'Haritora']);
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { FluentVariable } from '@fluent/bundle';
|
||||
import { FlatDeviceTracker } from './app';
|
||||
import { ReactLocalization } from '@fluent/react';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
type StatusSystemStateAction =
|
||||
| StatusSystemStateFixedAction
|
||||
|
||||
@@ -1,40 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BodyPart, TrackerDataT, TrackerInfoT, TrackerStatus } from 'solarxr-protocol';
|
||||
import { BodyPart, TrackerDataT, TrackerInfoT } from 'solarxr-protocol';
|
||||
import { QuaternionFromQuatT, QuaternionToEulerDegrees } from '@/maths/quaternion';
|
||||
import { useAppContext } from './app';
|
||||
import { ReactLocalization, useLocalization } from '@fluent/react';
|
||||
import { useDataFeedConfig } from './datafeed-config';
|
||||
import { Quaternion, Vector3 } from 'three';
|
||||
import { Vector3FromVec3fT } from '@/maths/vector3';
|
||||
|
||||
export function useTrackers() {
|
||||
const { trackers } = useAppContext();
|
||||
|
||||
return {
|
||||
trackers,
|
||||
useAssignedTrackers: () =>
|
||||
useMemo(
|
||||
() =>
|
||||
trackers.filter(({ tracker }) => tracker.info?.bodyPart !== BodyPart.NONE),
|
||||
[trackers]
|
||||
),
|
||||
useUnassignedTrackers: () =>
|
||||
useMemo(
|
||||
() =>
|
||||
trackers.filter(({ tracker }) => tracker.info?.bodyPart === BodyPart.NONE),
|
||||
[trackers]
|
||||
),
|
||||
useConnectedIMUTrackers: () =>
|
||||
useMemo(
|
||||
() =>
|
||||
trackers.filter(
|
||||
({ tracker }) =>
|
||||
tracker.status !== TrackerStatus.DISCONNECTED && tracker.info?.isImu
|
||||
),
|
||||
[trackers]
|
||||
),
|
||||
};
|
||||
}
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { trackerFromIdAtom } from '@/store/app-store';
|
||||
|
||||
export function getTrackerName(l10n: ReactLocalization, info: TrackerInfoT | null) {
|
||||
if (info?.customName) return info?.customName;
|
||||
@@ -115,19 +87,9 @@ export function useTrackerFromId(
|
||||
trackerNum: string | number | undefined,
|
||||
deviceId: string | number | undefined
|
||||
) {
|
||||
const { trackers } = useAppContext();
|
||||
|
||||
const tracker = useMemo(
|
||||
() =>
|
||||
trackers.find(
|
||||
({ tracker }) =>
|
||||
trackerNum &&
|
||||
deviceId &&
|
||||
tracker?.trackerId?.trackerNum == trackerNum &&
|
||||
tracker?.trackerId?.deviceId?.id == deviceId
|
||||
),
|
||||
[trackers, trackerNum, deviceId]
|
||||
const trackerAtom = useMemo(
|
||||
() => trackerFromIdAtom({ trackerNum, deviceId }),
|
||||
[trackerNum, deviceId]
|
||||
);
|
||||
|
||||
return tracker;
|
||||
return useAtomValue(trackerAtom);
|
||||
}
|
||||
|
||||
94
gui/src/store/app-store.ts
Normal file
94
gui/src/store/app-store.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { atom } from 'jotai';
|
||||
import {
|
||||
BodyPart,
|
||||
DataFeedUpdateT,
|
||||
DeviceDataT,
|
||||
TrackerDataT,
|
||||
TrackerStatus,
|
||||
} from 'solarxr-protocol';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { isEqual } from '@react-hookz/deep-equal';
|
||||
|
||||
export interface FlatDeviceTracker {
|
||||
device?: DeviceDataT;
|
||||
tracker: TrackerDataT;
|
||||
}
|
||||
|
||||
export const ignoredTrackersAtom = atom(new Set<string>());
|
||||
|
||||
export const datafeedAtom = atom(new DataFeedUpdateT());
|
||||
|
||||
export const devicesAtom = selectAtom(
|
||||
datafeedAtom,
|
||||
(datafeed) => datafeed.devices,
|
||||
isEqual
|
||||
);
|
||||
|
||||
export const flatTrackersAtom = atom((get) => {
|
||||
const devices = get(devicesAtom);
|
||||
|
||||
return devices.flatMap<FlatDeviceTracker>((device) =>
|
||||
device.trackers.map((tracker) => ({ tracker, device }))
|
||||
);
|
||||
});
|
||||
|
||||
export const assignedTrackersAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
return trackers.filter(({ tracker }) => tracker.info?.bodyPart !== BodyPart.NONE);
|
||||
});
|
||||
|
||||
export const unassignedTrackersAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
return trackers.filter(({ tracker }) => tracker.info?.bodyPart === BodyPart.NONE);
|
||||
});
|
||||
|
||||
export const connectedIMUTrackersAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
return trackers.filter(
|
||||
({ tracker }) =>
|
||||
tracker.status !== TrackerStatus.DISCONNECTED && tracker.info?.isImu
|
||||
);
|
||||
});
|
||||
|
||||
export const computedTrackersAtom = selectAtom(
|
||||
datafeedAtom,
|
||||
(datafeed) => datafeed.syntheticTrackers.map((tracker) => ({ tracker })),
|
||||
isEqual
|
||||
);
|
||||
|
||||
export const bonesAtom = selectAtom(
|
||||
datafeedAtom,
|
||||
(datafeed) => datafeed.bones,
|
||||
isEqual
|
||||
);
|
||||
|
||||
export const hasHMDTrackerAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
|
||||
return trackers.some(
|
||||
(tracker) =>
|
||||
tracker.tracker.info?.bodyPart === BodyPart.HEAD &&
|
||||
(tracker.tracker.info.isHmd || tracker.tracker.position?.y !== undefined)
|
||||
);
|
||||
});
|
||||
|
||||
export const trackerFromIdAtom = ({
|
||||
trackerNum,
|
||||
deviceId,
|
||||
}: {
|
||||
trackerNum: string | number | undefined;
|
||||
deviceId: string | number | undefined;
|
||||
}) =>
|
||||
selectAtom(
|
||||
atom((get) =>
|
||||
get(flatTrackersAtom).find(
|
||||
({ tracker }) =>
|
||||
trackerNum &&
|
||||
deviceId &&
|
||||
tracker?.trackerId?.trackerNum == trackerNum &&
|
||||
tracker?.trackerId?.deviceId?.id == deviceId
|
||||
)
|
||||
),
|
||||
(a) => a,
|
||||
isEqual
|
||||
);
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
import { AppState } from '@/hooks/app';
|
||||
import { DeviceDataT } from 'solarxr-protocol';
|
||||
|
||||
export function getSentryOrCompute(enabled = false) {
|
||||
// if sentry is already initialized - SKIP
|
||||
@@ -65,21 +65,19 @@ export function getSentryOrCompute(enabled = false) {
|
||||
return newClient;
|
||||
}
|
||||
|
||||
export function updateSentryContext(state: AppState) {
|
||||
export function updateSentryContext(devices: DeviceDataT[]) {
|
||||
// We filter out the shit we dont want. We dont need rotation data or ip addresses
|
||||
const trackers = (state.datafeed?.devices || []).map(
|
||||
({ hardwareInfo, trackers, id }) => ({
|
||||
id: id?.id,
|
||||
hardwareInfo: { ...hardwareInfo, ipAddress: undefined },
|
||||
trackers: trackers.map(({ info, trackerId }) => ({
|
||||
info,
|
||||
trackerId: {
|
||||
trackerNum: trackerId?.trackerNum,
|
||||
deviceId: trackerId?.deviceId?.id,
|
||||
},
|
||||
})),
|
||||
})
|
||||
);
|
||||
const trackers = (devices || []).map(({ hardwareInfo, trackers, id }) => ({
|
||||
id: id?.id,
|
||||
hardwareInfo: { ...hardwareInfo, ipAddress: undefined },
|
||||
trackers: trackers.map(({ info, trackerId }) => ({
|
||||
info,
|
||||
trackerId: {
|
||||
trackerNum: trackerId?.trackerNum,
|
||||
deviceId: trackerId?.deviceId?.id,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Will send the latest context to sentry when an error happens
|
||||
Sentry.setContext('trackers', {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineConfig, PluginOption } from 'vite';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import jotaiReactRefresh from 'jotai/babel/plugin-react-refresh';
|
||||
|
||||
const commitHash = execSync('git rev-parse --verify --short HEAD').toString().trim();
|
||||
const versionTag = execSync('git --no-pager tag --sort -taggerdate --points-at HEAD')
|
||||
@@ -41,7 +42,7 @@ export default defineConfig({
|
||||
__GIT_CLEAN__: gitClean,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
react({ babel: { plugins: [jotaiReactRefresh] } }),
|
||||
i18nHotReload(),
|
||||
visualizer() as PluginOption,
|
||||
sentryVitePlugin({
|
||||
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -32,9 +32,12 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0(react-hook-form@7.53.0(react@18.3.1))
|
||||
'@react-hookz/deep-equal':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@react-three/drei':
|
||||
specifier: ^9.114.3
|
||||
version: 9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
|
||||
version: 9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
|
||||
'@react-three/fiber':
|
||||
specifier: ^8.17.10
|
||||
version: 8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
|
||||
@@ -83,6 +86,9 @@ importers:
|
||||
ip-num:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
jotai:
|
||||
specifier: ^2.12.2
|
||||
version: 2.12.2(@types/react@18.3.11)(react@18.3.1)
|
||||
prompts:
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
@@ -819,6 +825,10 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@react-hookz/deep-equal@3.0.3':
|
||||
resolution: {integrity: sha512-SLy+NmiDpncqc2d9TR4Y4R7f8lUFOQK9WbnIq02A6wDxy+dTHfA2Np0dPvj0SFp6i1nqERLmEUe9MxPLuO/IqA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@react-spring/animated@9.6.1':
|
||||
resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==}
|
||||
peerDependencies:
|
||||
@@ -2556,8 +2566,8 @@ packages:
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@9.0.21:
|
||||
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||
immer@10.1.1:
|
||||
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
||||
|
||||
immutable@4.3.6:
|
||||
resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==}
|
||||
@@ -2793,6 +2803,18 @@ packages:
|
||||
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
|
||||
hasBin: true
|
||||
|
||||
jotai@2.12.2:
|
||||
resolution: {integrity: sha512-oN8715y7MkjXlSrpyjlR887TOuc/NLZMs9gvgtfWH/JP47ChwO0lR2ijSwBvPMYyXRAPT+liIAhuBavluKGgtA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=17.0.0'
|
||||
react: '>=17.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -5049,6 +5071,8 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@react-hookz/deep-equal@3.0.3': {}
|
||||
|
||||
'@react-spring/animated@9.6.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-spring/shared': 9.6.1(react@18.3.1)
|
||||
@@ -5083,7 +5107,7 @@ snapshots:
|
||||
|
||||
'@react-spring/types@9.6.1': {}
|
||||
|
||||
'@react-three/drei@9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)':
|
||||
'@react-three/drei@9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.7
|
||||
'@mediapipe/tasks-vision': 0.10.8
|
||||
@@ -5107,7 +5131,7 @@ snapshots:
|
||||
three-mesh-bvh: 0.7.8(three@0.163.0)
|
||||
three-stdlib: 2.30.3(three@0.163.0)
|
||||
troika-three-text: 0.49.1(three@0.163.0)
|
||||
tunnel-rat: 0.1.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1)
|
||||
tunnel-rat: 0.1.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)
|
||||
utility-types: 3.11.0
|
||||
uuid: 9.0.1
|
||||
zustand: 3.7.2(react@18.3.1)
|
||||
@@ -6983,7 +7007,7 @@ snapshots:
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@9.0.21:
|
||||
immer@10.1.1:
|
||||
optional: true
|
||||
|
||||
immutable@4.3.6: {}
|
||||
@@ -7219,6 +7243,11 @@ snapshots:
|
||||
|
||||
jiti@1.21.6: {}
|
||||
|
||||
jotai@2.12.2(@types/react@18.3.11)(react@18.3.1):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
react: 18.3.1
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
@@ -8817,9 +8846,9 @@ snapshots:
|
||||
tslib: 1.14.1
|
||||
typescript: 4.8.2
|
||||
|
||||
tunnel-rat@0.1.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1):
|
||||
tunnel-rat@0.1.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
zustand: 4.5.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1)
|
||||
zustand: 4.5.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
@@ -9145,12 +9174,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
zustand@4.5.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1):
|
||||
zustand@4.5.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.2.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
immer: 9.0.21
|
||||
immer: 10.1.1
|
||||
react: 18.3.1
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user