Initial 1.3.x rework

This commit is contained in:
Sacha Weatherstone
2022-07-26 08:52:23 +10:00
parent ff6019098b
commit a2280ab96e
163 changed files with 6383 additions and 14397 deletions
+205
View File
@@ -0,0 +1,205 @@
import type React from "react";
import { useEffect, useState } from "react";
import { fromByteArray, toByteArray } from "base64-js";
import {
Button,
EyeOffIcon,
EyeOpenIcon,
FormField,
IconButton,
majorScale,
Pane,
RefreshIcon,
SelectField,
Switch,
TextInputField,
Tooltip,
} from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/meshtasticjs";
export interface SettingsPanelProps {
channel: Protobuf.Channel;
}
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { connection } = useDevice();
const [loading, setLoading] = useState(false);
const [keySize, setKeySize] = useState<128 | 256>(256);
const [pskHidden, setPskHidden] = useState(true);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
setValue,
} = 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)),
},
});
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 (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
{channel?.index !== 0 && (
<>
<FormField
label="Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch
height={24}
marginLeft="auto"
checked={value}
{...field}
/>
)}
/>
</FormField>
<TextInputField
label="Name"
description="Max transmit power in dBm"
{...register("name")}
/>
</>
)}
<Pane display="flex" gap={majorScale(1)}>
<SelectField
width="100%"
label="Key Size"
description="Desired size of generated key."
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
>
<option value={128}>128 Bit</option>
<option value={256}>256 Bit</option>
</SelectField>
<Tooltip content="Generate new key">
<IconButton
marginTop={majorScale(6)}
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
): void => {
e.preventDefault();
const key = new Uint8Array(keySize / 8);
crypto.getRandomValues(key);
setValue("psk", fromByteArray(key));
}}
icon={<RefreshIcon />}
/>
</Tooltip>
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<TextInputField
width="100%"
label="Pre-Shared Key"
description="Max transmit power in dBm"
type={pskHidden ? "password" : "text"}
{...register("psk")}
/>
<Tooltip content={pskHidden ? "Show key" : "Hide key"}>
<Button
marginTop={majorScale(6)}
width={majorScale(12)}
onClick={(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
): void => {
e.preventDefault();
setPskHidden(!pskHidden);
}}
iconBefore={pskHidden ? <EyeOpenIcon /> : <EyeOffIcon />}
>
{pskHidden ? "Show" : "Hide"}
</Button>
</Tooltip>
</Pane>
<FormField
label="Uplink Enabled"
description="Description"
isInvalid={!!errors.uplinkEnabled?.message}
validationMessage={errors.uplinkEnabled?.message}
>
<Controller
name="uplinkEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Downlink Enabled"
description="Description"
isInvalid={!!errors.downlinkEnabled?.message}
validationMessage={errors.downlinkEnabled?.message}
>
<Controller
name="downlinkEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};
+66
View File
@@ -0,0 +1,66 @@
import type React from "react";
import { useState } from "react";
import { Button, LayerIcon, LayerOutlineIcon, Tooltip } from "evergreen-ui";
import { IoQrCodeOutline } from "react-icons/io5";
import { QRDialog } from "@app/components/Dialog/QRDialog.js";
import { Tab, TabbedContent } from "@components/layout/page/TabbedContent";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/meshtasticjs";
import { Channel } from "./Channel.js";
export const ChannelsPage = (): JSX.Element => {
const { channels, config } = useDevice();
const [QRDialogOpen, setQRDialogOpen] = useState(false);
const [activeChannel, setActiveChannel] = useState(0);
const tabs: Tab[] = channels.map((channel) => {
return {
key: channel.config.index,
name: channel.config.settings?.name.length
? channel.config.settings.name
: channel.config.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.config.index}`,
icon:
channel.config.role !== Protobuf.Channel_Role.DISABLED
? LayerIcon
: LayerOutlineIcon,
element: () => <Channel channel={channel.config} />,
};
});
return (
<>
<QRDialog
isOpen={QRDialogOpen}
close={() => {
setQRDialogOpen(false);
}}
channels={channels.map((ch) => ch.config)}
loraConfig={config.lora}
/>
<TabbedContent
active={activeChannel}
setActive={setActiveChannel}
tabs={tabs}
actions={[
() => (
<Tooltip content="Open QR code generator">
<Button
onClick={() => {
setQRDialogOpen(true);
}}
iconBefore={IoQrCodeOutline}
>
QR Code
</Button>
</Tooltip>
),
]}
/>
</>
);
};
+82
View File
@@ -0,0 +1,82 @@
import type React from "react";
import { useState } from "react";
import { Pane, Tab, Tablist } from "evergreen-ui";
import { useDevice } from "@app/core/stores/deviceStore.js";
import { Device } from "@components/PageComponents/Config/Device.js";
import { Display } from "@components/PageComponents/Config/Display.js";
import { LoRa } from "@components/PageComponents/Config/LoRa.js";
import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js";
import { User } from "@components/PageComponents/Config/User.js";
import { WiFi } from "@components/PageComponents/Config/WiFi.js";
export const DeviceConfig = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState(0);
const { hardware } = useDevice();
const configSections = [
{
label: "User",
element: User,
},
{
label: "Device",
element: Device,
},
{
label: "Position",
element: Position,
},
{
label: "Power",
element: Power,
},
{
label: "WiFi",
element: WiFi,
disabled: !hardware.hasWifi,
},
{
label: "Display",
element: Display,
},
{
label: "LoRa",
element: LoRa,
},
// Channels
// Interface
];
return (
<Pane display="flex">
<Pane flexBasis={150} marginRight={24}>
<Tablist>
{configSections.map((Config, index) => (
<Tab
key={index}
direction="vertical"
isSelected={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
disabled={Config.disabled}
>
{Config.label}
</Tab>
))}
</Tablist>
</Pane>
<Pane flex="1">
{configSections.map((Config, index) => (
<Pane
key={index}
display={index === selectedIndex ? "block" : "none"}
>
<Config.element />
</Pane>
))}
</Pane>
</Pane>
);
};
+78
View File
@@ -0,0 +1,78 @@
import type React from "react";
import { useState } from "react";
import { Pane, Tab, Tablist } from "evergreen-ui";
import { ExternalNotification } from "@app/components/PageComponents/ModuleConfig/ExternalNotification.js";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
export const ModuleConfig = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState(0);
const configSections = [
{
label: "MQTT",
element: MQTT,
},
{
label: "Serial",
element: Serial,
},
{
label: "External Notification",
element: ExternalNotification,
},
{
label: "Store & Forward",
element: StoreForward,
},
{
label: "Range Test",
element: RangeTest,
},
{
label: "Telemetry",
element: Telemetry,
},
{
label: "Canned Message",
element: CannedMessage,
},
// Channels
// Interface
];
return (
<Pane display="flex">
<Pane flexBasis={150} marginRight={24}>
<Tablist>
{configSections.map((Config, index) => (
<Tab
key={index}
direction="vertical"
isSelected={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{Config.label}
</Tab>
))}
</Tablist>
</Pane>
<Pane flex="1">
{configSections.map((Config, index) => (
<Pane
key={index}
display={index === selectedIndex ? "block" : "none"}
>
<Config.element />
</Pane>
))}
</Pane>
</Pane>
);
};
+36
View File
@@ -0,0 +1,36 @@
import type React from "react";
import { useState } from "react";
import { CogIcon, CubeIcon } from "evergreen-ui";
import { Tab, TabbedContent } from "@components/layout/page/TabbedContent";
import { DeviceConfig } from "./DeviceConfig.js";
import { ModuleConfig } from "./ModuleConfig.js";
export const ConfigPage = (): JSX.Element => {
const [activeConfig, setActiveConfig] = useState(0);
const tabs: Tab[] = [
{
key: 0,
name: "Device Config",
icon: CogIcon,
element: DeviceConfig,
},
{
key: 1,
name: "Module Config",
icon: CubeIcon,
element: ModuleConfig,
},
];
return (
<TabbedContent
active={activeConfig}
setActive={setActiveConfig}
tabs={tabs}
/>
);
};
-118
View File
@@ -1,118 +0,0 @@
import type React from 'react';
import { Button } from '@components/generic/button/Button';
import { Card } from '@components/generic/Card';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Debug = (): JSX.Element => {
const hardwareInfo = useAppSelector(
(state) => state.meshtastic.radio.hardware,
);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware.myNodeNum,
);
const node = useAppSelector((state) =>
state.meshtastic.nodes.find(
(node) => node.data.num === hardwareInfo.myNodeNum,
),
);
return (
<div className="flex flex-col gap-4 p-4 md:flex-row">
<Card className="flex-grow">
<div className="grid grid-cols-4 gap-4">
<Button
onClick={(): void => {
void connection.configure();
}}
>
Configure
</Button>
<Button
onClick={(): void => {
void connection.getAllChannels();
}}
>
Get All Channels
</Button>
<Button
onClick={(): void => {
const packet = Protobuf.AdminMessage.create({
variant: {
oneofKind: 'getConfigRequest',
getConfigRequest:
Protobuf.AdminMessage_ConfigType.LORA_CONFIG,
},
});
void connection.sendPacket(
Protobuf.AdminMessage.toBinary(packet),
Protobuf.PortNum.ADMIN_APP,
myNodeNum,
true,
0,
true,
false,
async (num) => {
return await Promise.resolve();
},
);
}}
>
Get All Config
</Button>
<Button
onClick={(): void => {
void connection.getConfig(
Protobuf.AdminMessage_ConfigType.DISPLAY_CONFIG,
async (id) => {
console.log(`RESPONSE - ${id}`);
return Promise.resolve();
},
);
}}
>
Get Display Config
</Button>
<Button
onClick={(): void => {
void connection.getConfig(
Protobuf.AdminMessage_ConfigType.LORA_CONFIG,
async (id) => {
console.log(`RESPONSE - ${id}`);
return Promise.resolve();
},
);
}}
>
Get LoRa Config
</Button>
<Button
onClick={(): void => {
void connection.setConfig(
Protobuf.Config.create({
payloadVariant: {
oneofKind: 'lora',
lora: Protobuf.Config_LoRaConfig.create({
modemPreset:
Protobuf.Config_LoRaConfig_ModemPreset.MidSlow,
}),
},
}),
async (id) => {
console.log(`RESPONSE - ${id}`);
return Promise.resolve();
},
);
}}
>
Set LoRa Config
</Button>
</div>
</Card>
</div>
);
};
+17
View File
@@ -0,0 +1,17 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import { useDevice } from "@app/core/stores/deviceStore.js";
export const Environment = (): JSX.Element => {
const { nodes } = useDevice();
return (
<Pane>
{nodes.map((node, index) => (
<Pane key={index}>{JSON.stringify(node.environmentMetrics)}</Pane>
))}
</Pane>
);
};
+22 -64
View File
@@ -1,13 +1,9 @@
import type React from 'react';
import type React from "react";
import { AnimatePresence, m } from 'framer-motion';
import { FiFilePlus } from 'react-icons/fi';
import useSWR from 'swr';
import { Pane } from "evergreen-ui";
import useSWR from "swr";
import { Button } from '@components/generic/button/Button';
import { Card } from '@components/generic/Card';
import { fetcher } from '@core/utils/fetcher';
import { useAppSelector } from '@hooks/useAppSelector';
import { fetcher } from "@core/utils/fetcher";
export interface File {
nameModified: string;
@@ -28,65 +24,27 @@ export interface Files {
}
export const FileBrowser = (): JSX.Element => {
const connectionParams = useAppSelector(
(state) => state.app.connectionParams,
);
const appState = useAppSelector((state) => state.app);
const meshtasticState = useAppSelector((state) => state.meshtastic);
const { data } = useSWR<Files>(
`${connectionParams.HTTP.tls ? 'https' : 'http'}://${
connectionParams.HTTP.address
}${
meshtasticState.radio.hardware.firmwareVersion.includes('1.2')
? '/json/spiffs/browse/static'
: '/json/fs/browse/static'
}`,
fetcher,
"http://meshtastic.local/json/fs/browse/static",
fetcher
);
return (
<div className="flex h-full p-4">
<Card
title="File Browser"
actions={<Button icon={<FiFilePlus />}>Upload File</Button>}
className="flex-grow flex-col"
>
<div className="h-full px-4">
<AnimatePresence>
{(!data || data?.data.files.length === 0) && (
<div className="flex h-full w-full">
<m.img
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="m-auto h-64 w-64 text-green-500"
src={appState.darkMode ? '/Files_Dark.svg' : '/Files.svg'}
/>
</div>
)}
</AnimatePresence>
{data?.data.files.map((file) => (
<div
key={file.name}
className="flex h-10 w-full border-b border-gray-400 px-4 font-medium dark:border-gray-600 dark:text-white"
>
<div className="my-auto w-1/3">
<a
target="_blank"
rel="noopener noreferrer"
href={`${connectionParams.HTTP.tls ? 'https' : 'http'}://${
connectionParams.HTTP.address
}/${file.name.replace('static/', '')}`}
>
{file.name.replace('static/', '').replace('.gz', '')}
</a>
</div>
<div className="my-auto w-1/3"></div>
</div>
))}
</div>
</Card>
</div>
<Pane>
{data?.data.files.map((file) => (
<Pane key={file.name}>
<a
target="_blank"
rel="noopener noreferrer"
href={`http://meshtastic.local/json/fs/browse/static/${file.name.replace(
"static/",
""
)}`}
>
{file.name.replace("static/", "").replace(".gz", "")}
</a>
</Pane>
))}
</Pane>
);
};
+39 -74
View File
@@ -1,81 +1,46 @@
import type React from 'react';
import { useState } from 'react';
import type React from "react";
import { useState } from "react";
import { FiFile, FiInfo } from 'react-icons/fi';
import { MdSubject } from 'react-icons/md';
import { RiPinDistanceFill } from 'react-icons/ri';
import { VscDebug, VscExtensions } from 'react-icons/vsc';
import { DocumentIcon, GanttChartIcon, RainIcon } from "evergreen-ui";
import { ExternalSection } from '@components/generic/Sidebar/ExternalSection';
import { Layout } from '@components/layout';
import { Debug } from '@pages/Extensions/Debug';
import { FileBrowser } from '@pages/Extensions/FileBrowser';
import { Info } from '@pages/Extensions/Info';
import { Logs } from '@pages/Extensions/Logs';
import { useDevice } from "@app/core/stores/deviceStore.js";
import { Tab, TabbedContent } from "@components/layout/page/TabbedContent";
import { FileBrowser } from "@pages/Extensions/FileBrowser";
export const Extensions = (): JSX.Element => {
const [selectedExtension, setSelectedExtension] = useState<
'info' | 'logs' | 'fileBrowser' | 'rangeTest' | 'debug'
>('info');
import { Environment } from "./Environment.js";
export const ExtensionsPage = (): JSX.Element => {
const [activeExtension, setActiveExtension] = useState(0);
const { hardware } = useDevice();
const tabs: Tab[] = [
{
key: 0,
name: "File Browser",
icon: DocumentIcon,
element: FileBrowser,
disabled: !hardware.hasWifi,
},
{
key: 1,
name: "Range Test",
icon: GanttChartIcon,
element: FileBrowser,
disabled: !hardware.hasWifi,
},
{
key: 2,
name: "Environment",
icon: RainIcon,
element: Environment,
},
];
return (
<Layout
title="Extensions"
icon={<VscExtensions />}
sidebarContents={
<div className="absolute flex w-full flex-col dark:bg-primaryDark">
<ExternalSection
onClick={(): void => {
setSelectedExtension('info');
}}
icon={<FiInfo />}
active={selectedExtension === 'info'}
title="Node Info"
/>
<ExternalSection
onClick={(): void => {
setSelectedExtension('logs');
}}
icon={<MdSubject />}
active={selectedExtension === 'logs'}
title="Logs"
/>
<ExternalSection
onClick={(): void => {
setSelectedExtension('fileBrowser');
}}
icon={<FiFile />}
active={selectedExtension === 'fileBrowser'}
title="File Browser"
/>
<ExternalSection
onClick={(): void => {
setSelectedExtension('rangeTest');
}}
icon={<RiPinDistanceFill />}
active={selectedExtension === 'rangeTest'}
title="Range Test"
/>
<ExternalSection
onClick={(): void => {
setSelectedExtension('debug');
}}
icon={<VscDebug />}
active={selectedExtension === 'debug'}
title="Debug"
/>
</div>
}
>
<div className="w-full">
{selectedExtension === 'info' && <Info />}
{selectedExtension === 'logs' && <Logs />}
{selectedExtension === 'fileBrowser' && <FileBrowser />}
{selectedExtension === 'debug' && <Debug />}
</div>
</Layout>
<TabbedContent
active={activeExtension}
setActive={setActiveExtension}
tabs={tabs}
/>
);
};
-50
View File
@@ -1,50 +0,0 @@
import type React from 'react';
import { FiRefreshCw } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import { IconButton } from '@components/generic/button/IconButton';
import { Card } from '@components/generic/Card';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Hashicon } from '@emeraldpay/hashicon-react';
import { useAppSelector } from '@hooks/useAppSelector';
export const Info = (): JSX.Element => {
const hardwareInfo = useAppSelector(
(state) => state.meshtastic.radio.hardware,
);
const node = useAppSelector((state) =>
state.meshtastic.nodes.find(
(node) => node.data.num === hardwareInfo.myNodeNum,
),
);
return (
<div className="flex h-full flex-col gap-4 p-4 xl:flex-row">
<Card
title="Connected Node Details"
actions={<IconButton icon={<FiRefreshCw />} />}
className="xl:w-3/5"
>
<div className="m-auto flex flex-col gap-2">
<Hashicon value={hardwareInfo.myNodeNum.toString()} size={180} />
<div className="text-center text-lg font-medium dark:text-white">
{node?.data.user?.longName || 'Unknown'}
</div>
</div>
{/* <img
src="https://docs.rakwireless.com/assets/images/wisblock/rak5005-o/overview/RAK5005-O_buy.png"
className="-rotate-90"
/> */}
</Card>
<Card
title="Debug Information"
className="flex-grow"
actions={<CopyButton data={JSON.stringify(hardwareInfo)} />}
>
<JSONPretty className="overflow-y-auto" data={hardwareInfo} />
</Card>
</div>
);
};
-167
View File
@@ -1,167 +0,0 @@
import type React from 'react';
import { AnimatePresence, m } from 'framer-motion';
import { FiArrowRight, FiPaperclip, FiTrash } from 'react-icons/fi';
import { IconButton } from '@components/generic/button/IconButton';
import { Card } from '@components/generic/Card';
import { clearLogs } from '@core/slices/meshtasticSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
export const Logs = (): JSX.Element => {
const dispatch = useAppDispatch();
const meshtasticState = useAppSelector((state) => state.meshtastic);
const appState = useAppSelector((state) => state.app);
type lookupType = { [key: number]: string };
const emitterLookup: lookupType = {
[Types.Emitter.sendText]: 'text-rose-500',
[Types.Emitter.sendPacket]: 'text-pink-500',
[Types.Emitter.sendRaw]: 'text-fuchsia-500',
[Types.Emitter.setConfig]: 'text-purple-500',
[Types.Emitter.confirmSetConfig]: 'text-violet-500',
[Types.Emitter.setOwner]: 'text-indigo-500',
[Types.Emitter.setChannel]: 'text-blue-500',
[Types.Emitter.confirmSetChannel]: 'text-sky-500',
[Types.Emitter.deleteChannel]: 'text-cyan-500',
[Types.Emitter.getChannel]: 'text-teal-500',
[Types.Emitter.getAllChannels]: 'text-emerald-500',
[Types.Emitter.getConfig]: 'text-green-500',
[Types.Emitter.getOwner]: 'text-lime-500',
[Types.Emitter.configure]: 'text-yellow-500',
[Types.Emitter.handleFromRadio]: 'text-amber-500',
[Types.Emitter.handleMeshPacket]: 'text-orange-500',
[Types.Emitter.connect]: 'text-red-500',
[Types.Emitter.ping]: 'text-stone-500',
[Types.Emitter.readFromRadio]: 'text-zinc-500',
[Types.Emitter.writeToRadio]: 'text-gray-500',
[Types.Emitter.setDebugMode]: 'text-slate-500',
};
const levelLookup: lookupType = {
[Protobuf.LogRecord_Level.UNSET]: 'text-green-500',
[Protobuf.LogRecord_Level.CRITICAL]: 'text-purple-500',
[Protobuf.LogRecord_Level.ERROR]: 'text-red-500',
[Protobuf.LogRecord_Level.WARNING]: 'text-orange-500',
[Protobuf.LogRecord_Level.INFO]: 'text-blue-500',
[Protobuf.LogRecord_Level.DEBUG]: 'text-neutral-500',
[Protobuf.LogRecord_Level.TRACE]: 'text-slate-500',
};
return (
<div className="flex h-full flex-col gap-4 p-4">
<Card
title="Device Logs"
actions={
<IconButton
icon={<FiTrash />}
onClick={(): void => {
dispatch(clearLogs());
}}
/>
}
className="flex-grow overflow-y-auto"
>
<table className="table-cell flex-grow">
<tbody
className="
block h-full flex-col overflow-y-auto font-mono text-xs dark:text-gray-400"
>
<AnimatePresence>
{meshtasticState.logs.length === 0 && (
<div className="flex h-full w-full">
<m.img
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="m-auto h-64 w-64 text-green-500"
src={
appState.darkMode
? '/View_Code_Dark.svg'
: '/View_Code.svg'
}
/>
</div>
)}
{meshtasticState.logs.map((log, index) => (
<m.tr
key={index}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="group hover:bg-gray-300 dark:hover:bg-secondaryDark"
>
<m.td
className="w-6 cursor-pointer"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="m-auto pl-2 text-white group-hover:text-black dark:text-primaryDark dark:group-hover:text-gray-400">
<FiArrowRight />
</div>
</m.td>
<Wrapper>
{log.date
.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
.replaceAll('/', '-')
.replace(',', '')}
</Wrapper>
<Wrapper>
<div className={emitterLookup[log.emitter]}>
[{Types.EmitterScope[log.scope]}.
{Types.Emitter[log.emitter]}]
</div>
</Wrapper>
<Wrapper className={levelLookup[log.level]}>
[{Protobuf.LogRecord_Level[log.level]}]{/* </div> */}
</Wrapper>
<td
className={`m-auto ${
log.packet ? '' : 'dark:text-secondaryDark'
}`}
>
<FiPaperclip />
</td>
<td className="w-full truncate pl-1">{log.message}</td>
</m.tr>
// </ContextMenu>
))}
</AnimatePresence>
</tbody>
</table>
</Card>
</div>
);
};
const Wrapper = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}): JSX.Element => (
<td className={className}>
<m.div
className="-my-0.5 flex max-w-min cursor-pointer truncate rounded-sm px-0.5 hover:bg-gray-400 dark:hover:bg-gray-700"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
{children}
</m.div>
</td>
);
+18
View File
@@ -0,0 +1,18 @@
import type React from "react";
import { Pane } from "evergreen-ui";
import { useDevice } from "@app/core/stores/deviceStore.js";
export const InfoPage = (): JSX.Element => {
const { hardware, nodes } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
return (
<Pane>
{JSON.stringify(myNode)}
{JSON.stringify(hardware)}
</Pane>
);
};
-95
View File
@@ -1,95 +0,0 @@
import type React from 'react';
import { useCallback } from 'react';
import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa';
import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md';
import { IconButton } from '@components/generic/button/IconButton';
import type { MapStyle } from '@core/mapStyles';
import { MapStyles } from '@core/mapStyles';
import {
setExaggeration,
setHillShade,
setMapStyle,
} from '@core/slices/mapSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { useMapbox } from '@hooks/useMapbox';
export const MapContainer = (): JSX.Element => {
const dispatch = useAppDispatch();
const darkMode = useAppSelector((state) => state.app.darkMode);
const mapState = useAppSelector((state) => state.map);
const { ref } = useMapbox();
const ChangeMapStyle = useCallback(
(styleName: string, style: MapStyle) => {
dispatch(
setMapStyle(
mapState.style === styleName
? darkMode
? 'Dark'
: 'Light'
: style.title,
),
);
},
[dispatch, darkMode, mapState.style],
);
return (
<div
className="relative flex h-full w-full"
onContextMenu={(e): void => {
e.stopPropagation();
}}
>
<div className="absolute right-0 z-10 m-4 space-y-2 rounded-md border border-gray-400 bg-white p-2 shadow-md dark:border-gray-600 dark:bg-primaryDark">
<IconButton
active={mapState.style === 'Satellite'}
onClick={(): void => {
ChangeMapStyle('Satellite', MapStyles.Satellite);
}}
icon={<FaGlobeAfrica />}
/>
<div
className={`-m-1 space-y-2 rounded-md border-gray-400 p-1 dark:border-gray-600 ${
mapState.style === 'Outdoors' ? 'border' : ''
}`}
>
<IconButton
active={mapState.style === 'Outdoors'}
onClick={(): void => {
ChangeMapStyle('Outdoors', MapStyles.Outdoors);
}}
icon={<FaDirections />}
/>
{mapState.style === 'Outdoors' && (
<IconButton
active={mapState.hillShade}
onClick={(): void => {
dispatch(setHillShade(!mapState.hillShade));
}}
icon={<MdWbShade />}
/>
)}
</div>
<hr className="text-gray-400 dark:text-gray-200" />
<IconButton
active={mapState.exaggeration}
onClick={(): void => {
dispatch(setExaggeration(!mapState.exaggeration));
}}
icon={<FaMountain />}
/>
<IconButton icon={<MdFullscreen />} />
<IconButton icon={<MdRadar />} />
</div>
<div className="h-full w-full flex-grow" ref={ref} />
</div>
);
};
-45
View File
@@ -1,45 +0,0 @@
import type React from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { LngLatLike, MarkerOptions } from 'mapbox-gl';
import { Marker as MapboxMarker } from 'mapbox-gl';
import { useMapbox } from '@hooks/useMapbox';
export interface MarkerProps extends Omit<MarkerOptions, 'element'> {
children?: React.ReactNode;
center: LngLatLike;
}
export const Marker = ({
children,
center,
...props
}: MarkerProps): JSX.Element => {
const { map } = useMapbox();
const ref = useRef<HTMLDivElement>(document.createElement('div'));
const addMarker = useCallback((): void => {
if (map) {
const marker = new MapboxMarker(ref.current, props).setLngLat(center);
marker.addTo(map);
}
}, [map, center, props]);
useEffect(() => {
map?.on('load', () => {
addMarker();
});
}, [addMarker, map]);
useEffect(() => {
if (map?.loaded()) {
addMarker();
}
}, [addMarker, map]);
<div ref={ref}>{children}</div>;
return createPortal(children, ref.current);
};
+128 -76
View File
@@ -1,85 +1,137 @@
import type React from 'react';
import { useState } from 'react';
import type React from "react";
import { useEffect, useMemo, useRef } from "react";
import mapboxgl from 'mapbox-gl';
import { FiMapPin } from 'react-icons/fi';
import { RiRoadMapLine } from 'react-icons/ri';
import { Pane } from "evergreen-ui";
import { Layout } from '@components/layout';
import { MapboxProvider } from '@components/MapBox/MapboxProvider';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
import { MapContainer } from '@pages/Map/MapContainer';
import { Marker } from '@pages/Map/Marker';
import { NodeCard } from '@pages/Nodes/NodeCard';
import { useDevice } from "@app/core/stores/deviceStore.js";
import Point from "@arcgis/core/geometry/Point";
import Graphic from "@arcgis/core/Graphic";
import FeatureLayer from "@arcgis/core/layers/FeatureLayer";
import LabelClass from "@arcgis/core/layers/support/LabelClass";
import Map from "@arcgis/core/Map";
import LineCallout3D from "@arcgis/core/symbols/callouts/LineCallout3D";
import LabelSymbol3D from "@arcgis/core/symbols/LabelSymbol3D";
import TextSymbol3DLayer from "@arcgis/core/symbols/TextSymbol3DLayer";
import SceneView from "@arcgis/core/views/SceneView";
export const Map = (): JSX.Element => {
const [selectedNode, setSelectedNode] = useState<Protobuf.NodeInfo>();
export const MapPage = (): JSX.Element => {
const { nodes } = useDevice();
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware.myNodeNum,
const nodesWithPosition = nodes.filter((node) => node.data.position);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
console.log(nodesWithPosition);
}, [nodesWithPosition]);
const labelClass = useMemo(
() =>
new LabelClass({
labelExpressionInfo: {
expression: "$feature.name",
},
symbol: new LabelSymbol3D({
symbolLayers: [
new TextSymbol3DLayer({
text: "{name}",
material: {
color: "black",
},
halo: {
color: [255, 255, 255, 0.7],
size: 2,
},
font: {
size: 12,
weight: "bold",
},
size: 10,
}),
],
verticalOffset: {
screenLength: 150,
maxWorldLength: 2000,
minWorldLength: 30,
},
callout: new LineCallout3D({
size: 0.5,
color: [0, 0, 0],
border: {
color: [255, 255, 255],
},
}),
}),
}),
[]
);
return (
<MapboxProvider>
{nodes.map((node) => {
return (
node.data.position && (
<Marker
key={node.data.num}
center={
new mapboxgl.LngLat(
node.data.position.longitudeI / 1e7,
node.data.position.latitudeI / 1e7,
)
const points: Graphic[] = nodesWithPosition.map(
(node, index) =>
node.data.position
? new Graphic({
geometry: new Point({
latitude: node.data.position.latitudeI / 1e7,
longitude: node.data.position.longitudeI / 1e7,
}),
attributes: {
ObjectID: index,
name: node.data.user?.longName,
},
})
: new Graphic() //should be undefined/removed from array
);
useEffect(() => {
if (ref.current) {
const layer = new FeatureLayer({
labelsVisible: true,
labelingInfo: [labelClass],
source: points,
fields: [
{
name: "ObjectID",
alias: "ObjectID",
type: "oid",
},
{
name: "name",
alias: "Name",
type: "string",
},
],
});
const map = new Map({
basemap: "satellite",
ground: "world-elevation",
layers: [layer],
});
const scene = new SceneView({
container: ref.current,
map: map,
camera: {
position: nodesWithPosition[0]
? {
x: nodesWithPosition[0].data.position?.longitudeI ?? 0 / 1e7,
y: nodesWithPosition[0].data.position?.latitudeI ?? 0 / 1e7,
z: nodesWithPosition[0].data.position?.altitude ?? 0 / 1e7,
}
>
<div
onClick={(): void => {
setSelectedNode(node.data);
}}
className={`z-50 rounded-full border-2 bg-opacity-30 ${
node.data.num === selectedNode?.num
? 'border-green-500 bg-green-500'
: 'border-blue-500 bg-blue-500'
}`}
>
<div className="m-4 ">
<FiMapPin className="h-5 w-5" />
</div>
</div>
</Marker>
)
);
})}
<Layout
title="Nodes"
icon={<RiRoadMapLine />}
sidebarContents={
<div className="flex flex-col gap-2">
{!nodes.length && (
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
No nodes found.
</span>
)}
: {
y: -35.59, //Longitude
x: 148, //Latitude
z: 200, //Meters
},
tilt: 75,
},
});
scene.on("click", (event) => {
void scene.hitTest(event).then((point) => {
console.log(point);
});
});
}
}, [labelClass, points]);
{nodes.map((node) => (
<NodeCard
key={node.data.num}
node={node.data}
isMyNode={node.data.num === myNodeNum}
selected={selectedNode?.num === node.data.num}
setSelected={(): void => {
setSelectedNode(node.data);
}}
/>
))}
</div>
}
>
<MapContainer />
</Layout>
</MapboxProvider>
);
return <Pane width="100%" height="100%" ref={ref} />;
};
+99 -101
View File
@@ -1,112 +1,110 @@
import type React from 'react';
import type React from "react";
import { ChangeEvent, useState } from "react";
import { m } from 'framer-motion';
import { FiSettings } from 'react-icons/fi';
import { MdPublic } from 'react-icons/md';
import TimeAgo from 'timeago-react';
import {
IconButton,
majorScale,
Pane,
SendMessageIcon,
TextInputField,
Tooltip,
} from "evergreen-ui";
import { IconButton } from '@components/generic/button/IconButton';
import { Tooltip } from '@components/generic/Tooltip';
import { SidebarItem } from '@components/layout/Sidebar/SidebarItem';
import { Hashicon } from '@emeraldpay/hashicon-react';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
import type { Channel } from "@core/stores/deviceStore.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Message } from "./Message.js";
export interface ChannelChatProps {
channel: Protobuf.Channel;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
channel: Channel;
}
export const ChannelChat = ({
channel,
selectedIndex,
setSelectedIndex,
}: ChannelChatProps): JSX.Element => {
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware,
).myNodeNum;
const nodes = useAppSelector((state) => state.meshtastic.nodes).filter(
(node) => node.data.num !== myNodeNum,
);
const chats = useAppSelector((state) => state.meshtastic.chats);
const channels = useAppSelector(
(state) => state.meshtastic.radio.channels,
).filter((ch) => ch.role !== Protobuf.Channel_Role.DISABLED);
export const ChannelChat = ({ channel }: ChannelChatProps): JSX.Element => {
const { nodes, connection } = useDevice();
const [currentMessage, setCurrentMessage] = useState("");
const sendMessage = (): void => {
console.log("SENDING TEXT");
void connection?.sendText(
currentMessage,
undefined,
true,
channel.config.index, //maybe channel.config.index--
(id) => {
// console.log(`Chat Index, ${chatIndex}`);
// console.log(`Chat Index --, ${chatIndex--}`);
// console.log(
// `Chat Index computed, ${isChannel ? chatIndex-- : chatIndex}`,
// );
// dispatch(
// ackMessage({
// chatIndex: channel.config.index,
// messageId: id,
// }),
// );
console.log("Message Sent");
return Promise.resolve();
}
);
setCurrentMessage("");
};
return (
<SidebarItem
key={channel.index}
selected={channel.index === selectedIndex}
setSelected={(): void => {
setSelectedIndex(channel.index);
}}
actions={<IconButton nested icon={<FiSettings />} />}
>
<Tooltip
content={
channel.settings?.name.length
? channel.settings.name
: `CH: ${channel.index}`
}
<Pane display="flex" flexDirection="column" flexGrow={1}>
<Pane display="flex" flexDirection="column" flexGrow={1}>
{channel.messages.map((message, index) => (
<Message
key={index}
message={message.message.data}
ack={message.ack}
rxTime={message.received}
lastMsgSameUser={
index === 0
? false
: channel.messages[index - 1].message.packet.from ===
message.message.packet.from
}
sender={
nodes.find(
(node) => node.data.num === message.message.packet.from
)?.data
}
/>
))}
</Pane>
<form
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<div className="flex h-8 w-8 rounded-full bg-gray-300 dark:bg-primaryDark dark:text-white">
<div className="m-auto">
{channel.role === Protobuf.Channel_Role.PRIMARY ? (
<MdPublic />
) : (
<p>
{channel.settings?.name.length
? channel.settings.name.substring(0, 3).toUpperCase()
: `CH: ${channel.index}`}
</p>
)}
</div>
</div>
</Tooltip>
{chats[channel.index]?.messages.length ? (
<>
<div className="mx-2 flex h-8">
{[
...new Set(
chats[channel.index]?.messages.flatMap(({ message }) => [
message.packet.from,
]),
),
]
.sort()
.map((nodeId) => {
return (
<Tooltip
key={nodeId}
content={
nodes.find((node) => node.data.num === nodeId)?.data.user
?.longName ?? 'UNK'
}
>
<div className="flex h-full">
<m.div
whileHover={{ scale: 1.1 }}
className="my-auto -ml-2"
>
<Hashicon value={nodeId.toString()} size={20} />
</m.div>
</div>
</Tooltip>
);
})}
</div>
<div className="my-auto ml-auto text-xs font-semibold dark:text-gray-400">
{chats[channel.index].messages.length ? (
<TimeAgo datetime={chats[channel.index].lastInterraction} />
) : (
<div>No messages</div>
)}
</div>
</>
) : (
<div className="my-auto dark:text-white">No messages</div>
)}
</SidebarItem>
<Pane display="flex" gap={majorScale(1)}>
<TextInputField
marginTop="auto"
minLength={2}
width="100%"
label=""
placeholder="Enter Message"
marginBottom={0}
value={currentMessage}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setCurrentMessage(e.target.value);
}}
/>
<Tooltip content="Send">
<IconButton
icon={SendMessageIcon}
marginTop={majorScale(2)}
width={majorScale(8)}
/>
</Tooltip>
</Pane>
</form>
</Pane>
);
};
-40
View File
@@ -1,40 +0,0 @@
import type React from 'react';
import { FiSettings } from 'react-icons/fi';
import { IconButton } from '@components/generic/button/IconButton';
import { SidebarItem } from '@components/layout/Sidebar/SidebarItem';
import { Hashicon } from '@emeraldpay/hashicon-react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface DmChatProps {
node: Protobuf.NodeInfo;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
}
export const DmChat = ({
node,
selectedIndex,
setSelectedIndex,
}: DmChatProps): JSX.Element => {
return (
<SidebarItem
key={node.num}
selected={node.num === selectedIndex}
setSelected={(): void => {
setSelectedIndex(node.num);
}}
actions={<IconButton nested icon={<FiSettings />} />}
>
<div className="flex dark:text-white">
<div className="m-auto">
<Hashicon value={node.num.toString()} size={32} />
</div>
</div>
<div className="my-auto mr-auto font-semibold dark:text-white">
{node.user?.longName ?? 'Unknown'}
</div>
</SidebarItem>
);
};
+70 -58
View File
@@ -1,10 +1,17 @@
import type React from 'react';
import type React from "react";
import { FiClock } from 'react-icons/fi';
import {
majorScale,
Pane,
Pulsar,
Small,
Strong,
Text,
TimeIcon,
} from "evergreen-ui";
import { Tooltip } from '@components/generic/Tooltip';
import { Hashicon } from '@emeraldpay/hashicon-react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
import { Hashicon } from "@emeraldpay/hashicon-react";
import type { Protobuf } from "@meshtastic/meshtasticjs";
export interface MessageProps {
lastMsgSameUser: boolean;
@@ -22,67 +29,72 @@ export const Message = ({
sender,
}: MessageProps): JSX.Element => {
return (
<div className="group mb-3 hover:bg-gray-200 dark:hover:bg-tertiaryDark">
<Pane marginBottom={majorScale(1)} className="group hover:bg-gray-200">
{lastMsgSameUser ? (
<div
className={`mx-6 -mt-3 flex justify-between ${
lastMsgSameUser ? '' : 'py-1'
}`}
<Pane
marginX={majorScale(2)}
display="flex"
justifyContent="space-between"
marginTop={-majorScale(1)}
className={`${lastMsgSameUser ? "" : "py-1"}`}
>
<div className="flex gap-2">
<Tooltip content={rxTime.toString()}>
<div className="my-auto ml-auto w-8 pt-1 text-xs text-transparent group-hover:text-gray-500 dark:group-hover:text-gray-400">
{rxTime
.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
.replace('AM', '')
.replace('PM', '')}
</div>
</Tooltip>
<div
className={`my-auto dark:text-gray-300 ${
ack ? '' : 'animate-pulse dark:text-gray-500'
}`}
<Pane
display="flex"
position="relative"
gap={majorScale(1)}
className="gap-2"
>
<Small
marginY="auto"
marginLeft="auto"
width={majorScale(3)}
paddingTop={majorScale(1)}
className="pt-1 text-transparent group-hover:text-gray-500"
>
{rxTime
.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})
.replace("AM", "")
.replace("PM", "")}
</Small>
<Text marginY="auto" className={`${ack ? "" : "animate-pulse"}`}>
{message}
</div>
</div>
<Tooltip content="Response time">
<div className="flex gap-1 pt-1 text-xs text-transparent group-hover:text-gray-500 dark:group-hover:text-gray-400">
<FiClock className="mt-0.5" />
<div>25s</div>
</div>
</Tooltip>
</div>
</Text>
<Pulsar />
</Pane>
<Pane
display="flex"
gap={majorScale(1)}
paddingTop={majorScale(1)}
className="text-transparent group-hover:text-gray-500"
>
<TimeIcon />
<Small>25s</Small>
</Pane>
</Pane>
) : (
<div className="mx-6 flex gap-2">
<div className="my-auto w-8">
<Pane display="flex" marginX={majorScale(2)} gap={majorScale(1)}>
<Pane marginY="auto" width={majorScale(3)}>
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
</div>
<div>
<div className="flex gap-2">
<div className="cursor-default text-sm font-semibold hover:underline dark:text-white">
{sender?.user?.longName ?? 'UNK'}
</div>
<div className="my-auto text-xs dark:text-gray-400">
</Pane>
<Pane>
<Pane display="flex" gap={majorScale(1)}>
<Strong cursor="default" size={500} className="hover:underline">
{sender?.user?.longName ?? "UNK"}
</Strong>
<Small marginY="auto">
{rxTime.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
<div
className={`dark:text-gray-300 ${
ack ? '' : 'animate-pulse dark:text-gray-400'
}`}
>
{message}
</div>
</div>
</div>
</Small>
</Pane>
<Text className={`${ack ? "" : "animate-pulse"}`}>{message}</Text>
</Pane>
</Pane>
)}
</div>
</Pane>
);
};
-87
View File
@@ -1,87 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { ackMessage } from '@core/slices/meshtasticSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
export interface MessageBarProps {
chatIndex: number;
}
export const MessageBar = ({ chatIndex }: MessageBarProps): JSX.Element => {
const dispatch = useAppDispatch();
const meshtasticState = useAppSelector((state) => state.meshtastic);
const [isChannel, setIsChannel] = useState(false);
useState(() => {
setIsChannel(
meshtasticState.radio.channels.findIndex(
(channel) => channel.index === chatIndex,
) !== -1,
);
});
const [currentMessage, setCurrentMessage] = useState('');
const sendMessage = (): void => {
if (meshtasticState.ready) {
void connection.sendText(
currentMessage,
isChannel ? undefined : chatIndex,
true,
isChannel ? chatIndex-- : 0,
(id) => {
console.log(`Chat Index, ${chatIndex}`);
console.log(`Chat Index --, ${chatIndex--}`);
console.log(
`Chat Index computed, ${isChannel ? chatIndex-- : chatIndex}`,
);
dispatch(
ackMessage({
chatIndex: isChannel ? chatIndex-- : chatIndex,
messageId: id,
}),
);
return Promise.resolve();
},
);
setCurrentMessage('');
}
};
return (
<div className="mx-auto flex w-full space-x-2 bg-gray-50 bg-transparent p-3 text-gray-500 dark:text-gray-400">
<div className="mx-auto flex w-full max-w-4xl">
<form
className="relative flex w-full space-x-2"
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<Input
type="text"
minLength={2}
placeholder="Enter Message"
disabled={!meshtasticState.ready}
value={currentMessage}
className="mr-16"
onChange={(e): void => {
setCurrentMessage(e.target.value);
}}
/>
<div className="absolute right-1 flex h-10">
<div className="z-10 my-auto mr-0.5 rounded-md border bg-secondaryDark bg-opacity-50 p-1 font-mono text-xs text-white">
{currentMessage.length}/255
</div>
</div>
</form>
</div>
</div>
);
};
+46 -111
View File
@@ -1,121 +1,56 @@
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import type React from "react";
import { FiHash, FiMessageCircle } from 'react-icons/fi';
import {
CircleIcon,
EditIcon,
IconButton,
Pane,
RingIcon,
Tooltip,
} from "evergreen-ui";
import { IconButton } from '@components/generic/button/IconButton';
import { Layout } from '@components/layout';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { ChannelChat } from '@pages/Messages/ChannelChat';
import { DmChat } from '@pages/Messages/DmChat';
import { Message } from '@pages/Messages/Message';
import { MessageBar } from '@pages/Messages/MessageBar';
import { Tab, TabbedContent } from "@components/layout/page/TabbedContent.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Messages = (): JSX.Element => {
const [selectedChatIndex, setSelectedChatIndex] = useState<number>(0);
import { ChannelChat } from "./ChannelChat.js";
const chatRef = useRef<HTMLDivElement>(null);
export const MessagesPage = (): JSX.Element => {
const { channels, activeChat, setActiveChat, setActivePage } = useDevice();
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware,
).myNodeNum;
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const chats = useAppSelector((state) => state.meshtastic.chats);
const channels = useAppSelector(
(state) => state.meshtastic.radio.channels,
).filter((ch) => ch.role !== Protobuf.Channel_Role.DISABLED);
useEffect(() => {
if (chatRef.current) {
chatRef.current.scrollTop = chatRef.current.scrollHeight;
}
}, [channels]);
const tabs: Tab[] = channels.map((channel) => {
return {
key: channel.config.index,
name: channel.config.settings?.name.length
? channel.config.settings?.name
: channel.config.index === 0
? "Primary"
: `Ch ${channel.config.index}`,
icon: channel.messages.length ? RingIcon : CircleIcon,
element: () => <ChannelChat channel={channel} />,
disabled: channel.config.role === Protobuf.Channel_Role.DISABLED,
};
});
return (
<Layout
title="Message Groups"
icon={<FiMessageCircle />}
sidebarContents={
<div className="flex flex-col gap-2">
{channels
.filter((channel) => channel.settings?.name !== 'admin')
.map((channel) => (
<ChannelChat
key={channel.index}
channel={channel}
selectedIndex={selectedChatIndex}
setSelectedIndex={setSelectedChatIndex}
<Pane display="flex" flexDirection="column" width="100%">
<TabbedContent
active={activeChat}
setActive={setActiveChat}
tabs={tabs}
actions={[
() => (
<Tooltip content="Edit Channels">
<IconButton
icon={EditIcon}
onClick={() => {
setActivePage("channels");
}}
/>
))}
{nodes.length !== 0 && channels.length !== 0 && (
<div className="mx-2 border-b border-gray-400 dark:border-gray-600" />
)}
{nodes
.filter((node) => node.data.num !== myNodeNum)
.map((node) => (
<DmChat
key={node.data.num}
node={node.data}
selectedIndex={selectedChatIndex}
setSelectedIndex={setSelectedChatIndex}
/>
))}
</div>
}
>
<div className="flex w-full flex-col">
<div className="flex w-full justify-between border-b border-gray-400 px-2 dark:border-gray-600 dark:text-gray-300">
<div className="my-auto flex gap-2 py-2 text-sm">
<IconButton nested icon={<FiHash className="h-4 w-4" />} />
<div className="my-auto">
{channels.findIndex((ch) => ch.index === selectedChatIndex) !==
-1 ? (
<span className="text-gray-500 dark:text-gray-400">
{channels[selectedChatIndex]?.settings?.name.length
? channels[selectedChatIndex]?.settings?.name
: channels[selectedChatIndex]?.role ===
Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `Channel: ${channels[selectedChatIndex]?.index}`}
</span>
) : (
<span className="text-gray-500 dark:text-gray-400">
{nodes.find((node) => node.data.num === selectedChatIndex)
?.data.user?.longName ?? 'Unknown'}
</span>
)}
</div>
</div>
</div>
<div
ref={chatRef}
className="flex flex-grow flex-col space-y-2 overflow-y-auto border-b border-gray-400 bg-gray-300 pb-6 dark:border-gray-600 dark:bg-secondaryDark"
>
<div className="mt-auto">
{chats[selectedChatIndex]?.messages.map((message, index) => (
<Message
key={index}
message={message.message.data}
ack={message.ack}
rxTime={message.received}
lastMsgSameUser={
index === 0
? false
: chats[selectedChatIndex].messages[index - 1].message
.packet.from === message.message.packet.from
}
sender={
nodes.find(
(node) => node.data.num === message.message.packet.from,
)?.data
}
/>
))}
</div>
</div>
<MessageBar chatIndex={selectedChatIndex} />
</div>
</Layout>
</Tooltip>
),
]}
/>
</Pane>
);
};
-189
View File
@@ -1,189 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { m } from 'framer-motion';
import { LngLat } from 'mapbox-gl';
import { BiCrown } from 'react-icons/bi';
import {
FiAlignLeft,
FiCode,
FiMapPin,
FiSliders,
FiUser,
} from 'react-icons/fi';
import { IoTelescope } from 'react-icons/io5';
import { MdGpsFixed, MdGpsNotFixed, MdGpsOff } from 'react-icons/md';
import JSONPretty from 'react-json-pretty';
import { IconButton } from '@components/generic/button/IconButton';
import { CollapsibleSection } from '@components/generic/Sidebar/CollapsibleSection';
import { SidebarOverlay } from '@components/generic/Sidebar/SidebarOverlay';
import { Tooltip } from '@components/generic/Tooltip';
import { SidebarItem } from '@components/layout/Sidebar/SidebarItem';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Hashicon } from '@emeraldpay/hashicon-react';
import { useMapbox } from '@hooks/useMapbox';
import type { Protobuf } from '@meshtastic/meshtasticjs';
type PositionConfidence = 'high' | 'low' | 'none';
export interface NodeCardProps {
node: Protobuf.NodeInfo;
isMyNode: boolean;
selected: boolean;
setSelected: () => void;
}
export const NodeCard = ({
node,
isMyNode,
selected,
setSelected,
}: NodeCardProps): JSX.Element => {
const { map } = useMapbox();
const [infoOpen, setInfoOpen] = useState(false);
const [PositionConfidence, setPositionConfidence] =
useState<PositionConfidence>('none');
useEffect(() => {
setPositionConfidence(
node.position
? new Date(node.position.posTimestamp * 1000) >
new Date(new Date().getTime() - 1000 * 60 * 30)
? 'high'
: 'low'
: 'none',
);
}, [node.position]);
return (
<>
<SidebarItem
selected={selected}
setSelected={setSelected}
actions={
<>
<IconButton
nested
tooltip={PositionConfidence !== 'none' ? 'Fly to Node' : ''}
disabled={PositionConfidence === 'none'}
onClick={(e): void => {
e.stopPropagation();
setSelected();
if (PositionConfidence !== 'none' && node.position) {
map?.flyTo({
center: new LngLat(
node.position.longitudeI / 1e7,
node.position.latitudeI / 1e7,
),
zoom: 16,
});
}
}}
icon={
PositionConfidence === 'high' ? (
<MdGpsFixed />
) : PositionConfidence === 'low' ? (
<MdGpsNotFixed />
) : (
<MdGpsOff />
)
}
/>
<IconButton
nested
tooltip="Show Node Info"
onClick={(e): void => {
e.stopPropagation();
setInfoOpen(true);
}}
icon={<FiAlignLeft />}
/>
</>
}
>
<div className="flex dark:text-white">
<div className="relative m-auto">
{isMyNode && (
<Tooltip content="Your Node">
<m.div
whileHover={{ scale: 1.05 }}
className="absolute -right-1 -top-1 rounded-full bg-yellow-500 p-0.5"
>
<BiCrown className="h-3 w-3" />
</m.div>
</Tooltip>
)}
<Hashicon value={node.num.toString()} size={32} />
</div>
</div>
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
{node.lastHeard
? new Date(node.lastHeard).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
: 'Never'}
</div>
</SidebarItem>
<SidebarOverlay
title={`Node ${node.user?.longName ?? 'UNK'} `}
open={infoOpen}
close={(): void => {
setInfoOpen(false);
}}
direction="x"
>
<CollapsibleSection title="User" icon={<FiUser />}>
<div className="flex p-2">
<div className="m-auto flex flex-col gap-2">
<Hashicon value={node.num.toString()} size={180} />
<div className="text-center text-lg font-medium dark:text-white">
{node?.user?.longName || 'Unknown'}
</div>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Location" icon={<FiMapPin />}>
<>
<div className="flex h-10 select-none justify-between rounded-md border border-gray-400 bg-transparent bg-gray-300 px-1 text-gray-500 dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
{node.position ? (
<>
<div className="my-auto px-1">
{(node.position.latitudeI / 1e7).toPrecision(6)}
,&nbsp;
{(node.position?.longitudeI / 1e7).toPrecision(6)}
</div>
<CopyButton
data={
node.position
? `${node.position.latitudeI / 1e7},${
node.position.longitudeI / 1e7
}`
: ''
}
/>
</>
) : (
<div className="my-auto px-1">No location data received</div>
)}
</div>
</>
</CollapsibleSection>
<CollapsibleSection title="Line of Sight" icon={<IoTelescope />}>
<div>Info</div>
</CollapsibleSection>
<CollapsibleSection title="Administration" icon={<FiSliders />}>
<div>Info</div>
</CollapsibleSection>
<CollapsibleSection title="Debug" icon={<FiCode />}>
<>
<div className="fixed right-0 mr-6">
<CopyButton data={JSON.stringify(node)} />
</div>
<JSONPretty className="max-w-sm" data={node} />
</>
</CollapsibleSection>
</SidebarOverlay>
</>
);
};
-14
View File
@@ -1,14 +0,0 @@
import type React from 'react';
import { Card } from '@components/generic/Card';
export const NotFound = (): JSX.Element => {
return (
<Card>
<h3>The requested file or directory could not be found</h3>
<h4>Better luck next time</h4>
<br />
<br />
</Card>
);
};
-210
View File
@@ -1,210 +0,0 @@
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { FiMapPin } from 'react-icons/fi';
import { RiRoadMapLine } from 'react-icons/ri';
import { Layout } from '@components/layout';
import { MapboxProvider } from '@components/MapBox/MapboxProvider';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
import { MapContainer } from '@app/pages/Map_old/MapContainer';
import { Marker } from '@app/pages/Map_old/Marker';
import { NodeCard } from '@pages/Nodes/NodeCard';
//
import { nodes } from './nodes';
//
import WebMap from '@arcgis/core/WebMap';
import Map from '@arcgis/core/Map';
import WebScene from '@arcgis/core/WebScene';
import SceneView from '@arcgis/core/views/SceneView';
import FeatureLayer from '@arcgis/core/layers/FeatureLayer';
import Point from '@arcgis/core/geometry/Point';
import Graphic from '@arcgis/core/Graphic';
import GraphicsLayer from '@arcgis/core/layers/GraphicsLayer';
import TextSymbol from '@arcgis/core/symbols/TextSymbol';
import Feature from '@arcgis/core/widgets/Feature';
import SimpleRenderer from '@arcgis/core/renderers/SimpleRenderer';
import LabelSymbol3D from '@arcgis/core/symbols/LabelSymbol3D';
import TextSymbol3DLayer from '@arcgis/core/symbols/TextSymbol3DLayer';
import LineCallout3D from '@arcgis/core/symbols/callouts/LineCallout3D';
import LabelClass from '@arcgis/core/layers/support/LabelClass';
export const MapPage = (): JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const labelClass = new LabelClass({
labelExpressionInfo: {
expression: '$feature.name',
},
symbol: new LabelSymbol3D({
symbolLayers: [
new TextSymbol3DLayer({
text: '{name}',
material: {
color: 'black',
},
halo: {
color: [255, 255, 255, 0.7],
size: 2,
},
font: {
size: 12,
weight: 'bold',
},
size: 10,
}),
],
verticalOffset: {
screenLength: 150,
maxWorldLength: 2000,
minWorldLength: 30,
},
callout: new LineCallout3D({
size: 0.5,
color: [0, 0, 0],
border: {
color: [255, 255, 255],
},
}),
}),
});
const points: Graphic[] = nodes.map(
(node, index) =>
new Graphic({
geometry: new Point({
latitude: node.lat,
longitude: node.lng,
}),
attributes: {
ObjectID: index,
name: node.name,
},
}),
);
useEffect(() => {
if (ref.current) {
const layer = new FeatureLayer({
labelsVisible: true,
labelingInfo: [labelClass],
source: points,
fields: [
{
name: 'ObjectID',
alias: 'ObjectID',
type: 'oid',
},
{
name: 'name',
alias: 'Name',
type: 'string',
},
],
});
const map = new Map({
basemap: 'satellite',
ground: 'world-elevation',
layers: [layer],
});
const scene = new SceneView({
container: ref.current,
map: map,
camera: {
position: {
y: -35.59, //Longitude
x: 148, //Latitude
z: 200, //Meters
},
tilt: 75,
},
});
}
}, []);
return (
<Layout
title="Nodes"
icon={<RiRoadMapLine />}
sidebarContents={<div className="flex flex-col gap-2"></div>}
>
<div className="w-full" ref={ref}></div>
</Layout>
);
// const [selectedNode, setSelectedNode] = useState<Protobuf.NodeInfo>();
// const nodes = useAppSelector((state) => state.meshtastic.nodes);
// const myNodeNum = useAppSelector(
// (state) => state.meshtastic.radio.hardware.myNodeNum,
// );
// return (
// <MapboxProvider>
// {nodes.map((node) => {
// return (
// node.data.position && (
// <Marker
// key={node.data.num}
// center={
// new mapboxgl.LngLat(
// node.data.position.longitudeI / 1e7,
// node.data.position.latitudeI / 1e7,
// )
// }
// >
// <div
// onClick={(): void => {
// setSelectedNode(node.data);
// }}
// className={`z-50 rounded-full border-2 bg-opacity-30 ${
// node.data.num === selectedNode?.num
// ? 'border-green-500 bg-green-500'
// : 'border-blue-500 bg-blue-500'
// }`}
// >
// <div className="m-4 ">
// <FiMapPin className="h-5 w-5" />
// </div>
// </div>
// </Marker>
// )
// );
// })}
// <Layout
// title="Nodes"
// icon={<RiRoadMapLine />}
// sidebarContents={
// <div className="flex flex-col gap-2">
// {!nodes.length && (
// <span className="p-4 text-sm text-gray-400 dark:text-gray-600">
// No nodes found.
// </span>
// )}
// {nodes.map((node) => (
// <NodeCard
// key={node.data.num}
// node={node.data}
// isMyNode={node.data.num === myNodeNum}
// selected={selectedNode?.num === node.data.num}
// setSelected={(): void => {
// setSelectedNode(node.data);
// }}
// />
// ))}
// </div>
// }
// >
// <MapContainer />
// </Layout>
// </MapboxProvider>
// );
};
File diff suppressed because it is too large Load Diff