Channel cleanup & sidebar unification

This commit is contained in:
Sacha Weatherstone
2022-01-17 01:35:45 +11:00
parent 568867de27
commit b929ca0136
17 changed files with 612 additions and 481 deletions
+131
View File
@@ -0,0 +1,131 @@
import React from 'react';
import mapbox from 'mapbox-gl';
import { FiAlignLeft } from 'react-icons/fi';
import {
MdAccountCircle,
MdGpsFixed,
MdGpsNotFixed,
MdGpsOff,
} from 'react-icons/md';
import TimeAgo from 'timeago-react';
import type { Node } from '@core/slices/meshtasticSlice';
import { useMapbox } from '@hooks/useMapbox';
import { IconButton } from '@meshtastic/components';
type PositionConfidence = 'high' | 'low' | 'none';
type NodeAge = 'young' | 'aging' | 'old' | 'dead';
export interface NodeCardProps {
node: Node;
isMyNode?: boolean;
setSelected: () => void;
}
export const NodeCard = ({
node,
isMyNode,
setSelected,
}: NodeCardProps): JSX.Element => {
const { map } = useMapbox();
// React.useEffect(() => {
// setSnrAverage(
// node.snr
// .slice(node.snr.length - 3, node.snr.length)
// .reduce((a, b) => a + b) / (node.snr.length > 3 ? 3 : node.snr.length),
// );
// }, [node.snr]);
const [PositionConfidence, setPositionConfidence] =
React.useState<PositionConfidence>('none');
const [age, setAge] = React.useState<NodeAge>('young');
React.useEffect(() => {
setAge(
node.lastHeard > new Date(Date.now() - 1000 * 60 * 15)
? 'young'
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 30)
? 'aging'
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 60)
? 'old'
: 'dead',
);
}, [node.lastHeard]);
React.useEffect(() => {
setPositionConfidence(
node.currentPosition
? new Date(node.currentPosition.posTimestamp * 1000) >
new Date(new Date().getTime() - 1000 * 60 * 30)
? 'high'
: 'low'
: 'none',
);
}, [node.currentPosition]);
return (
<div className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700">
<div className="flex w-full gap-1 p-2 bg-gray-100 rounded-md shadow-md dark:bg-primaryDark">
{isMyNode ? (
<MdAccountCircle className="my-auto" />
) : (
<div
className={`my-auto w-3 h-3 rounded-full ${
age === 'young'
? 'bg-green-500'
: age === 'aging'
? 'bg-yellow-500'
: age === 'old'
? 'bg-red-500'
: 'bg-gray-500'
}`}
/>
)}
<div className="my-auto">{node.user?.longName}</div>
<div className="my-auto ml-auto text-xs font-semibold">
{!isMyNode && (
<span>
{node.lastHeard.getTime() ? (
<TimeAgo datetime={node.lastHeard} />
) : (
'Never'
)}
</span>
)}
</div>
<IconButton
disabled={PositionConfidence === 'none'}
onClick={(e): void => {
e.stopPropagation();
if (PositionConfidence !== 'none' && node.currentPosition) {
map?.flyTo({
center: new mapbox.LngLat(
node.currentPosition.longitudeI / 1e7,
node.currentPosition.latitudeI / 1e7,
),
zoom: 16,
});
}
}}
icon={
PositionConfidence === 'high' ? (
<MdGpsFixed />
) : PositionConfidence === 'low' ? (
<MdGpsNotFixed />
) : (
<MdGpsOff />
)
}
/>
<IconButton
onClick={(): void => {
setSelected();
}}
icon={<FiAlignLeft />}
/>
{/* <FiBatteryCharging /> */}
</div>
</div>
);
};
@@ -0,0 +1,59 @@
import React from 'react';
import { FiCode, FiMapPin, FiSliders, FiUser } from 'react-icons/fi';
import { IoTelescope } from 'react-icons/io5';
import { DebugPanel } from '@app/components/pages/nodes/panels/DebugPanel';
import { InfoPanel } from '@app/components/pages/nodes/panels/InfoPanel';
import { PositionPanel } from '@app/components/pages/nodes/panels/PositionPanel';
import { Sidebar } from '@components/generic/Sidebar';
import { TabButton } from '@components/TabButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface NodeSidebarProps {
node: Node;
closeSidebar: () => void;
}
export const NodeSidebar = ({
node,
closeSidebar,
}: NodeSidebarProps): JSX.Element => {
return (
<Sidebar
title={node.number.toString()}
tagline={`${node.user?.longName}(${node.user?.shortName})`}
closeSidebar={closeSidebar}
>
<Tab.Group>
<div className="shadow-md">
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
<TabButton>
<FiUser />
</TabButton>
<TabButton>
<FiMapPin />
</TabButton>
<TabButton>
<IoTelescope />
</TabButton>
<TabButton>
<FiSliders />
</TabButton>
<TabButton>
<FiCode />
</TabButton>
</Tab.List>
</div>
<Tab.Panels className="flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
<InfoPanel />
<PositionPanel node={node} />
<Tab.Panel className="p-2">Content 3</Tab.Panel>
<Tab.Panel className="p-2">Remote Administration</Tab.Panel>
<DebugPanel node={node} />
</Tab.Panels>
</Tab.Group>
</Sidebar>
);
};
@@ -0,0 +1,22 @@
import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface DebugPanelProps {
node: Node;
}
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
return (
<Tab.Panel className="relative">
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(node)} />
</div>
<JSONPretty className="max-w-sm" data={node} />
</Tab.Panel>
);
};
@@ -0,0 +1,7 @@
import type React from 'react';
import { Tab } from '@headlessui/react';
export const InfoPanel = (): JSX.Element => {
return <Tab.Panel className="p-2">Info</Tab.Panel>;
};
@@ -0,0 +1,33 @@
import type React from 'react';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface PositionPanelProps {
node: Node;
}
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
return (
<Tab.Panel className="p-2">
{node.currentPosition && (
<div className="flex justify-between h-10 px-1 text-gray-500 bg-transparent bg-gray-200 border border-gray-300 rounded-md select-none dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
<div className="px-1 my-auto">
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},&nbsp;
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
</div>
<CopyButton
data={
node.currentPosition
? `${node.currentPosition.latitudeI / 1e7},${
node.currentPosition.longitudeI / 1e7
}`
: ''
}
/>
</div>
)}
</Tab.Panel>
);
};
@@ -0,0 +1,60 @@
import type React from 'react';
import { FaQrcode } from 'react-icons/fa';
import { FiCode, FiSliders } from 'react-icons/fi';
import { TabButton } from '@app/components/TabButton';
import { Sidebar } from '@components/generic/Sidebar';
import { Tab } from '@headlessui/react';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { DebugPanel } from './panels/DebugPanel';
import { QRCodePanel } from './panels/QRCodePanel';
import { SettingsPanel } from './panels/SettingsPanel';
export interface ChannelsSidebarProps {
channel?: Protobuf.Channel;
closeSidebar: () => void;
}
export const ChannelsSidebar = ({
channel,
closeSidebar,
}: ChannelsSidebarProps): JSX.Element => {
return (
<Sidebar
title={
channel
? channel.settings?.name.length
? channel.settings.name
: `Channel: ${channel.index}`
: 'Please select channel'
}
tagline={channel ? Protobuf.Channel_Role[channel.role] : '...'}
closeSidebar={closeSidebar}
>
{channel && (
<Tab.Group>
<div className="shadow-md">
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
<TabButton>
<FiSliders />
</TabButton>
<TabButton>
<FaQrcode />
</TabButton>
<TabButton>
<FiCode />
</TabButton>
</Tab.List>
</div>
<Tab.Panels className="flex flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
<SettingsPanel channel={channel} />
<QRCodePanel channel={channel} />
<DebugPanel channel={channel} />
</Tab.Panels>
</Tab.Group>
)}
</Sidebar>
);
};
@@ -0,0 +1,22 @@
import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface DebugPanelProps {
channel: Protobuf.Channel;
}
export const DebugPanel = ({ channel }: DebugPanelProps): JSX.Element => {
return (
<Tab.Panel className="relative">
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(channel)} />
</div>
<JSONPretty className="max-w-sm" data={channel} />
</Tab.Panel>
);
};
@@ -0,0 +1,23 @@
import type React from 'react';
import QRCode from 'react-qr-code';
import { Tab } from '@headlessui/react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface QRCodePanelProps {
channel: Protobuf.Channel;
}
export const QRCodePanel = ({ channel }: QRCodePanelProps): JSX.Element => {
return (
<Tab.Panel className="flex flex-grow p-2">
<div className="m-auto">
<QRCode
className="rounded-md"
value={`https://www.meshtastic.org/d/#${channel.index}`}
/>
</div>
</Tab.Panel>
);
};
@@ -0,0 +1,139 @@
import React from 'react';
import { fromByteArray, toByteArray } from 'base64-js';
import { useForm } from 'react-hook-form';
import { FiSave } from 'react-icons/fi';
import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
import { Loading } from '@app/components/generic/Loading';
import { connection } from '@app/core/connection';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input, Select } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface SettingsPanelProps {
channel: Protobuf.Channel;
}
export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
const [loading, setLoading] = React.useState(false);
const [keySize, setKeySize] = React.useState<128 | 256>(256);
const [pskHidden, setPskHidden] = React.useState(true);
const { register, handleSubmit, setValue, formState, reset } = useForm<
Omit<Protobuf.ChannelSettings, 'psk'> & { psk: string; enabled: boolean }
>({
defaultValues: {
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
},
});
React.useEffect(() => {
reset({
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
});
}, [channel, reset]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
const channelData = Protobuf.Channel.create({
role:
channel?.role === Protobuf.Channel_Role.PRIMARY
? Protobuf.Channel_Role.PRIMARY
: data.enabled
? Protobuf.Channel_Role.SECONDARY
: Protobuf.Channel_Role.DISABLED,
index: channel?.index,
settings: {
...data,
psk: toByteArray(data.psk ?? ''),
},
});
await connection.setChannel(channelData, (): Promise<void> => {
reset({ ...data });
setLoading(false);
return Promise.resolve();
});
});
return (
<Tab.Panel className="flex flex-col w-full">
{loading && <Loading />}
<form className="flex-grow gap-3 p-2">
{channel?.index !== 0 && (
<>
<Checkbox
label="Enabled"
{...register('enabled', { valueAsNumber: true })}
/>
<Input label="Name" {...register('name')} />
</>
)}
<Select
label="Key Size"
options={[
{ name: '128 Bit', value: 128 },
{ name: '256 Bit', value: 256 },
]}
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
/>
<Input
label="Pre-Shared Key"
type={pskHidden ? 'password' : 'text'}
disabled
action={
<>
<IconButton
onClick={(): void => {
setPskHidden(!pskHidden);
}}
icon={pskHidden ? <MdVisibility /> : <MdVisibilityOff />}
/>
<IconButton
onClick={(): void => {
const key = new Uint8Array(keySize);
crypto.getRandomValues(key);
setValue('psk', fromByteArray(key));
}}
icon={<MdRefresh />}
/>
</>
}
{...register('psk')}
/>
<Checkbox label="Uplink Enabled" {...register('uplinkEnabled')} />
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
</form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
await onSubmit();
}}
icon={<FiSave />}
/>
</div>
</div>
</Tab.Panel>
);
};