Initial 1.3.x rework
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
,
|
||||
{(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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user