refactor: switch to using Bun (#718)
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { Channel } from "@components/PageComponents/Channel.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Channel } from "@components/PageComponents/Channel.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Types } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { Types } from "@meshtastic/core";
|
||||
import i18next from "i18next";
|
||||
import { QrCodeIcon, UploadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -19,8 +19,11 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => {
|
||||
return channel.settings?.name.length
|
||||
? channel.settings?.name
|
||||
: channel.index === 0
|
||||
? i18next.t("page.broadcastLabel")
|
||||
: i18next.t("page.channelIndex", { ns: "channels", index: channel.index });
|
||||
? i18next.t("page.broadcastLabel")
|
||||
: i18next.t("page.channelIndex", {
|
||||
ns: "channels",
|
||||
index: channel.index,
|
||||
});
|
||||
};
|
||||
|
||||
const ChannelsPage = () => {
|
||||
@@ -34,50 +37,50 @@ const ChannelsPage = () => {
|
||||
const allChannels = Array.from(channels.values());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={<Sidebar />}
|
||||
label={currentChannel
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={<Sidebar />}
|
||||
label={
|
||||
currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: t("loading", { ns: "common" })}
|
||||
actions={[
|
||||
{
|
||||
key: "import",
|
||||
icon: UploadIcon,
|
||||
onClick() {
|
||||
setDialogOpen("import", true);
|
||||
},
|
||||
: t("loading", { ns: "common" })
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
key: "import",
|
||||
icon: UploadIcon,
|
||||
onClick() {
|
||||
setDialogOpen("import", true);
|
||||
},
|
||||
{
|
||||
key: "qr",
|
||||
icon: QrCodeIcon,
|
||||
onClick() {
|
||||
setDialogOpen("QR", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "qr",
|
||||
icon: QrCodeIcon,
|
||||
onClick() {
|
||||
setDialogOpen("QR", true);
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Tabs defaultValue="0">
|
||||
<TabsList className="dark:bg-slate-800">
|
||||
{allChannels.map((channel) => (
|
||||
<TabsTrigger
|
||||
key={channel.index}
|
||||
value={channel.index.toString()}
|
||||
className="dark:text-white"
|
||||
>
|
||||
{getChannelName(channel)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Tabs defaultValue="0">
|
||||
<TabsList className="dark:bg-slate-800">
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent key={channel.index} value={channel.index.toString()}>
|
||||
<Channel key={channel.index} channel={channel} />
|
||||
</TabsContent>
|
||||
<TabsTrigger
|
||||
key={channel.index}
|
||||
value={channel.index.toString()}
|
||||
className="dark:text-white"
|
||||
>
|
||||
{getChannelName(channel)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</Tabs>
|
||||
</PageLayout>
|
||||
</>
|
||||
</TabsList>
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent key={channel.index} value={channel.index.toString()}>
|
||||
<Channel key={channel.index} channel={channel} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
export default ChannelsPage;
|
||||
|
||||
@@ -6,25 +6,22 @@ import { Network } from "@components/PageComponents/Config/Network/index.tsx";
|
||||
import { Position } from "@components/PageComponents/Config/Position.tsx";
|
||||
import { Power } from "@components/PageComponents/Config/Power.tsx";
|
||||
import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentType, Suspense } from "react";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigProps {
|
||||
// We can get rid of this exception if we import every config schema and pass the union type
|
||||
// deno-lint-ignore no-explicit-any
|
||||
onFormInit: (methods: UseFormReturn<any>) => void;
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
type TabItem = {
|
||||
case: ValidConfigType;
|
||||
label: string;
|
||||
@@ -96,10 +93,8 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
|
||||
</span>
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
|
||||
</span>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
@@ -108,9 +103,9 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<ConfigSuspender configCase={tab.case}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</ConfigSuspender>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
import { AmbientLighting } from "@components/PageComponents/ModuleConfig/AmbientLighting.tsx";
|
||||
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
|
||||
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
|
||||
import { Audio } from "@components/PageComponents/ModuleConfig/Audio.tsx";
|
||||
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.tsx";
|
||||
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
|
||||
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.tsx";
|
||||
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.tsx";
|
||||
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
|
||||
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.tsx";
|
||||
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
|
||||
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
|
||||
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
|
||||
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDevice,
|
||||
type ValidModuleConfigType,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentType, Suspense } from "react";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigProps {
|
||||
// We can get rid of this exception if we import every config schema and pass the union type
|
||||
// deno-lint-ignore no-explicit-any
|
||||
onFormInit: (methods: UseFormReturn<any>) => void;
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
type TabItem = {
|
||||
case: ValidModuleConfigType;
|
||||
label: string;
|
||||
@@ -119,10 +116,8 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
|
||||
</span>
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
|
||||
</span>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
@@ -131,9 +126,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<ConfigSuspender moduleConfigCase={tab.case}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</ConfigSuspender>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||
import {
|
||||
@@ -15,10 +15,9 @@ import {
|
||||
SaveOff,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
const ConfigPage = () => {
|
||||
const {
|
||||
@@ -40,11 +39,14 @@ const ConfigPage = () => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onFormInit = (methods: UseFormReturn) => {
|
||||
setFormMethods(methods);
|
||||
};
|
||||
const onFormInit = useCallback(
|
||||
<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
||||
setFormMethods(methods as UseFormReturn);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
if (hasErrors()) {
|
||||
return toast({
|
||||
title: t("toast.validationError.title"),
|
||||
@@ -64,7 +66,7 @@ const ConfigPage = () => {
|
||||
case: newConfig.payloadVariant.case,
|
||||
}),
|
||||
});
|
||||
})
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -76,21 +78,24 @@ const ConfigPage = () => {
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newModuleConfig.payloadVariant.case,
|
||||
}),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await connection?.commitEditSettings().then(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset({}, {
|
||||
keepValues: true,
|
||||
});
|
||||
formMethods.reset(
|
||||
{},
|
||||
{
|
||||
keepValues: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
workingConfig.map((newConfig) => setConfig(newConfig));
|
||||
workingModuleConfig.map((newModuleConfig) =>
|
||||
setModuleConfig(newModuleConfig)
|
||||
setModuleConfig(newModuleConfig),
|
||||
);
|
||||
|
||||
removeWorkingConfig();
|
||||
@@ -104,24 +109,33 @@ const ConfigPage = () => {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
hasErrors,
|
||||
toast,
|
||||
t,
|
||||
workingConfig,
|
||||
connection,
|
||||
workingModuleConfig,
|
||||
formMethods,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
removeWorkingConfig,
|
||||
removeWorkingModuleConfig,
|
||||
]);
|
||||
|
||||
const handleReset = () => {
|
||||
const handleReset = useCallback(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset();
|
||||
}
|
||||
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
};
|
||||
}, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]);
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
<SidebarSection
|
||||
label={t("sidebar.label")}
|
||||
className="py-2 px-0"
|
||||
>
|
||||
<SidebarSection label={t("sidebar.label")} className="py-2 px-0">
|
||||
<SidebarButton
|
||||
label={t("navigation.radioConfig")}
|
||||
active={activeConfigSection === "device"}
|
||||
@@ -141,7 +155,7 @@ const ConfigPage = () => {
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[activeConfigSection, workingConfig, workingModuleConfig],
|
||||
[activeConfigSection, workingConfig, workingModuleConfig, t],
|
||||
);
|
||||
|
||||
const buttonOpacity = useMemo(() => {
|
||||
@@ -151,8 +165,10 @@ const ConfigPage = () => {
|
||||
const hasWorkingConfig = workingConfig.length > 0;
|
||||
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
|
||||
|
||||
const shouldShowButton = (isFormDirty && hasDirtyFields) ||
|
||||
hasWorkingConfig || hasWorkingModuleConfig;
|
||||
const shouldShowButton =
|
||||
(isFormDirty && hasDirtyFields) ||
|
||||
hasWorkingConfig ||
|
||||
hasWorkingModuleConfig;
|
||||
|
||||
return shouldShowButton ? "opacity-100" : "opacity-0";
|
||||
}, [
|
||||
@@ -166,71 +182,79 @@ const ConfigPage = () => {
|
||||
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
|
||||
}, [formMethods?.formState.errors]);
|
||||
|
||||
const actions = useMemo(() => [
|
||||
{
|
||||
key: "unsavedChanges",
|
||||
label: t("common:formValidation.unsavedChanges"),
|
||||
onClick: () => {},
|
||||
className: cn([
|
||||
"bg-blue-500 text-slate-900 hover:bg-initial",
|
||||
"transition-colors duration-200",
|
||||
buttonOpacity,
|
||||
"transition-opacity",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
icon: RefreshCwIcon,
|
||||
label: t("common:button.reset"),
|
||||
onClick: handleReset,
|
||||
className: cn([
|
||||
buttonOpacity,
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !isValid ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving ||
|
||||
!isValid ||
|
||||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
|
||||
iconClasses: !isValid
|
||||
? "text-red-400 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
className: cn([
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||
"disabled:hover:cursor-not-allowed cursor-pointer",
|
||||
]),
|
||||
onClick: handleSave,
|
||||
label: t("common:button.save"),
|
||||
},
|
||||
], [
|
||||
activeConfigSection,
|
||||
isSaving,
|
||||
isValid,
|
||||
buttonOpacity,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
]);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "unsavedChanges",
|
||||
label: t("common:formValidation.unsavedChanges"),
|
||||
onClick: () => {},
|
||||
className: cn([
|
||||
"bg-blue-500 text-slate-900 hover:bg-initial",
|
||||
"transition-colors duration-200",
|
||||
buttonOpacity,
|
||||
"transition-opacity",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
icon: RefreshCwIcon,
|
||||
label: t("common:button.reset"),
|
||||
onClick: handleReset,
|
||||
className: cn([
|
||||
buttonOpacity,
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !isValid ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled:
|
||||
isSaving ||
|
||||
!isValid ||
|
||||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
|
||||
iconClasses: !isValid
|
||||
? "text-red-400 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
className: cn([
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||
"disabled:hover:cursor-not-allowed cursor-pointer",
|
||||
]),
|
||||
onClick: handleSave,
|
||||
label: t("common:button.save"),
|
||||
},
|
||||
],
|
||||
[
|
||||
isSaving,
|
||||
isValid,
|
||||
buttonOpacity,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
handleReset,
|
||||
handleSave,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={leftSidebar}
|
||||
label={activeConfigSection === "device"
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={leftSidebar}
|
||||
label={
|
||||
activeConfigSection === "device"
|
||||
? t("navigation.radioConfig")
|
||||
: t("navigation.moduleConfig")}
|
||||
actions={actions}
|
||||
>
|
||||
{activeConfigSection === "device"
|
||||
? <DeviceConfig onFormInit={onFormInit} />
|
||||
: <ModuleConfig onFormInit={onFormInit} />}
|
||||
</PageLayout>
|
||||
</>
|
||||
: t("navigation.moduleConfig")
|
||||
}
|
||||
actions={actions}
|
||||
>
|
||||
{activeConfigSection === "device" ? (
|
||||
<DeviceConfig onFormInit={onFormInit} />
|
||||
) : (
|
||||
<ModuleConfig onFormInit={onFormInit} />
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
@@ -17,87 +17,69 @@ export const Dashboard = () => {
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3 px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Heading as="h3">
|
||||
{t("dashboard.title")}
|
||||
</Heading>
|
||||
<Subtle>
|
||||
{t("dashboard.description")}
|
||||
</Subtle>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length
|
||||
? (
|
||||
<ul className="grow divide-y divide-slate-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-4 sm:px-6"
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.getNode(device.hardware.myNodeNum)?.user
|
||||
?.longName ??
|
||||
t("unknown.shortName")}
|
||||
</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-slate-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.getNodesLength() === 0
|
||||
? 0
|
||||
: device.getNodesLength() - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon
|
||||
size={48}
|
||||
className="mx-auto text-text-secondary"
|
||||
/>
|
||||
<Heading as="h3">
|
||||
{t("dashboard.noDevicesTitle")}
|
||||
</Heading>
|
||||
<Subtle>
|
||||
{t("dashboard.noDevicesDescription")}
|
||||
</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="default"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
{t(
|
||||
"dashboard.button_newConnection",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 p-3 px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Heading as="h3">{t("dashboard.title")}</Heading>
|
||||
<Subtle>{t("dashboard.description")}</Subtle>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length ? (
|
||||
<ul className="grow divide-y divide-slate-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-4 sm:px-6"
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.getNode(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? t("unknown.shortName")}
|
||||
</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-slate-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.getNodesLength() === 0
|
||||
? 0
|
||||
: device.getNodesLength() - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
|
||||
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
|
||||
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="default"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
{t("dashboard.button_newConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
||||
import { Avatar } from "../../components/UI/Avatar.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { BaseMap } from "@components/Map.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
import { FunnelIcon, MapPinIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
|
||||
import { Map } from "@components/Map.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
||||
import { Avatar } from "../../components/UI/Avatar.tsx";
|
||||
|
||||
type NodePosition = {
|
||||
latitude: number;
|
||||
@@ -35,27 +35,25 @@ const MapPage = () => {
|
||||
|
||||
const { default: map } = useMap();
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<
|
||||
Protobuf.Mesh.NodeInfo | null
|
||||
>(null);
|
||||
const [selectedNode, setSelectedNode] =
|
||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||
|
||||
const validNodes = useMemo(
|
||||
() =>
|
||||
getNodes(
|
||||
(node): node is Protobuf.Mesh.NodeInfo =>
|
||||
Boolean(node.position?.latitudeI),
|
||||
getNodes((node): node is Protobuf.Mesh.NodeInfo =>
|
||||
Boolean(node.position?.latitudeI),
|
||||
),
|
||||
[getNodes],
|
||||
);
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(() =>
|
||||
defaultFilterValues
|
||||
const [filterState, setFilterState] = useState<FilterState>(
|
||||
() => defaultFilterValues,
|
||||
);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
|
||||
[validNodes, deferredFilterState],
|
||||
[validNodes, deferredFilterState, nodeFilter],
|
||||
);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
@@ -77,7 +75,9 @@ const MapPage = () => {
|
||||
|
||||
// Get the bounds of the map based on the nodes furtherest away from center
|
||||
const getMapBounds = useCallback(() => {
|
||||
if (!map || validNodes.length === 0) return;
|
||||
if (!map || validNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (validNodes.length === 1) {
|
||||
map.easeTo({
|
||||
@@ -131,27 +131,27 @@ const MapPage = () => {
|
||||
</Marker>
|
||||
);
|
||||
}),
|
||||
[filteredNodes, handleMarkerClick],
|
||||
[filteredNodes, handleMarkerClick, hasNodeError],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
||||
<Map onLoad={getMapBounds}>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={(wp.longitudeI ?? 0) / 1e7}
|
||||
latitude={(wp.latitudeI ?? 0) / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
<MapPinIcon size={16} />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{markers}
|
||||
{selectedNode && (() => {
|
||||
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
||||
<BaseMap onLoad={getMapBounds}>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={(wp.longitudeI ?? 0) / 1e7}
|
||||
latitude={(wp.latitudeI ?? 0) / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
<MapPinIcon size={16} />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{markers}
|
||||
{selectedNode &&
|
||||
(() => {
|
||||
const position = convertToLatLng(selectedNode.position);
|
||||
return (
|
||||
<Popup
|
||||
@@ -166,32 +166,31 @@ const MapPage = () => {
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</Map>
|
||||
</BaseMap>
|
||||
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-5" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
</>
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-5" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+179
-167
@@ -1,13 +1,25 @@
|
||||
import { messagesWithParamsRoute } from "@app/routes.tsx";
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
MessageState,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore/index.ts";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/core";
|
||||
import { getChannelName } from "@pages/Channels.tsx";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -16,19 +28,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import {
|
||||
MessageState,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore/index.ts";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { messagesWithParamsRoute } from "@app/routes.tsx";
|
||||
|
||||
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
|
||||
|
||||
@@ -51,11 +51,7 @@ export const MessagesPage = () => {
|
||||
resetUnread,
|
||||
connection,
|
||||
} = useDevice();
|
||||
const {
|
||||
getMyNodeNum,
|
||||
getMessages,
|
||||
setMessageState,
|
||||
} = useMessageStore();
|
||||
const { getMyNodeNum, getMessages, setMessageState } = useMessageStore();
|
||||
|
||||
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
||||
|
||||
@@ -66,14 +62,16 @@ export const MessagesPage = () => {
|
||||
const { t } = useTranslation(["messages", "channels", "ui"]);
|
||||
const deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
const navigateToChat = useCallback((type: MessageType, id: string) => {
|
||||
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
|
||||
navigate({ to: `/messages/${typeParam}/${id}` });
|
||||
}, [navigate]);
|
||||
const navigateToChat = useCallback(
|
||||
(type: MessageType, id: string) => {
|
||||
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
|
||||
navigate({ to: `/messages/${typeParam}/${id}` });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const chatType = type === "direct"
|
||||
? MessageType.Direct
|
||||
: MessageType.Broadcast;
|
||||
const chatType =
|
||||
type === "direct" ? MessageType.Direct : MessageType.Broadcast;
|
||||
const numericChatId = Number(chatId);
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
@@ -94,87 +92,96 @@ export const MessagesPage = () => {
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const isBroadcast = chatType === MessageType.Broadcast;
|
||||
|
||||
const filteredNodes = (): NodeInfoWithUnread[] => {
|
||||
const filteredNodes = useCallback((): NodeInfoWithUnread[] => {
|
||||
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
|
||||
|
||||
return getNodes((node) => {
|
||||
return getNodes((node: Protobuf.Mesh.NodeInfo) => {
|
||||
const longName = node.user?.longName?.toLowerCase() ?? "";
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? "";
|
||||
return longName.includes(lowerCaseSearchTerm) ||
|
||||
shortName.includes(lowerCaseSearchTerm);
|
||||
return (
|
||||
longName.includes(lowerCaseSearchTerm) ||
|
||||
shortName.includes(lowerCaseSearchTerm)
|
||||
);
|
||||
})
|
||||
.map((node) => ({
|
||||
.map((node: Protobuf.Mesh.NodeInfo) => ({
|
||||
...node,
|
||||
unreadCount: getUnreadCount(node.num) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
.sort((a: NodeInfoWithUnread, b: NodeInfoWithUnread) => {
|
||||
const diff = b.unreadCount - a.unreadCount;
|
||||
if (diff !== 0) return diff;
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
return Number(b.isFavorite) - Number(a.isFavorite);
|
||||
});
|
||||
};
|
||||
}, [deferredSearch, getNodes, getUnreadCount]);
|
||||
|
||||
const sendText = useCallback(async (message: string) => {
|
||||
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
|
||||
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
|
||||
const sendText = useCallback(
|
||||
async (message: string) => {
|
||||
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
|
||||
const channelValue = isDirect
|
||||
? Types.ChannelNumber.Primary
|
||||
: numericChatId;
|
||||
|
||||
let messageId: number | undefined;
|
||||
let messageId: number | undefined;
|
||||
|
||||
try {
|
||||
messageId = await connection?.sendText(
|
||||
message,
|
||||
toValue,
|
||||
true,
|
||||
channelValue,
|
||||
);
|
||||
if (messageId !== undefined) {
|
||||
try {
|
||||
messageId = await connection?.sendText(
|
||||
message,
|
||||
toValue,
|
||||
true,
|
||||
channelValue,
|
||||
);
|
||||
if (messageId !== undefined) {
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: numericChatId,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("sendText completed but messageId is undefined");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to send message:", e);
|
||||
const failedId = messageId ?? randId();
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: numericChatId,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("sendText completed but messageId is undefined");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to send message:", e);
|
||||
const failedId = messageId ?? randId();
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: numericChatId,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
numericChatId,
|
||||
chatId,
|
||||
chatType,
|
||||
connection,
|
||||
getMyNodeNum,
|
||||
setMessageState,
|
||||
]);
|
||||
},
|
||||
[
|
||||
numericChatId,
|
||||
chatType,
|
||||
connection,
|
||||
getMyNodeNum,
|
||||
setMessageState,
|
||||
isDirect,
|
||||
],
|
||||
);
|
||||
|
||||
const renderChatContent = () => {
|
||||
switch (chatType) {
|
||||
@@ -202,48 +209,52 @@ export const MessagesPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const leftSidebar = useMemo(() => (
|
||||
<Sidebar>
|
||||
<SidebarSection
|
||||
label={t("navigation.channels")}
|
||||
className="py-2 px-0"
|
||||
>
|
||||
{filteredChannels?.map((channel) => (
|
||||
<SidebarButton
|
||||
key={channel.index}
|
||||
count={getUnreadCount(channel.index)}
|
||||
label={channel.settings?.name ||
|
||||
(channel.index === 0
|
||||
? t("page.broadcastLabel", { ns: "channels" })
|
||||
: t("page.channelLabel", {
|
||||
index: channel.index,
|
||||
ns: "channels",
|
||||
}))}
|
||||
active={numericChatId === channel.index &&
|
||||
chatType === MessageType.Broadcast}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Broadcast, channel.index.toString());
|
||||
resetUnread(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon
|
||||
size={16}
|
||||
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
), [
|
||||
filteredChannels,
|
||||
numericChatId,
|
||||
chatType,
|
||||
isCollapsed,
|
||||
getUnreadCount,
|
||||
navigateToChat,
|
||||
resetUnread,
|
||||
t,
|
||||
]);
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
<SidebarSection label={t("navigation.channels")} className="py-2 px-0">
|
||||
{filteredChannels?.map((channel) => (
|
||||
<SidebarButton
|
||||
key={channel.index}
|
||||
count={getUnreadCount(channel.index)}
|
||||
label={
|
||||
channel.settings?.name ||
|
||||
(channel.index === 0
|
||||
? t("page.broadcastLabel", { ns: "channels" })
|
||||
: t("page.channelLabel", {
|
||||
index: channel.index,
|
||||
ns: "channels",
|
||||
}))
|
||||
}
|
||||
active={
|
||||
numericChatId === channel.index &&
|
||||
chatType === MessageType.Broadcast
|
||||
}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Broadcast, channel.index.toString());
|
||||
resetUnread(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon
|
||||
size={16}
|
||||
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[
|
||||
filteredChannels,
|
||||
numericChatId,
|
||||
chatType,
|
||||
isCollapsed,
|
||||
getUnreadCount,
|
||||
navigateToChat,
|
||||
resetUnread,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const rightSidebar = useMemo(
|
||||
() => (
|
||||
@@ -251,9 +262,10 @@ export const MessagesPage = () => {
|
||||
label=""
|
||||
className="px-0 flex flex-col h-full overflow-y-auto"
|
||||
>
|
||||
<label className="p-2 block">
|
||||
<label className="p-2 block" htmlFor="nodeSearch">
|
||||
<Input
|
||||
type="text"
|
||||
name="nodeSearch"
|
||||
placeholder={t("search.nodes")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
@@ -269,19 +281,18 @@ export const MessagesPage = () => {
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
preventCollapse
|
||||
label={node.user?.longName ??
|
||||
t("unknown.shortName")}
|
||||
label={node.user?.longName ?? t("unknown.shortName")}
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined}
|
||||
active={numericChatId === node.num &&
|
||||
chatType === MessageType.Direct}
|
||||
active={
|
||||
numericChatId === node.num && chatType === MessageType.Direct
|
||||
}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Direct, node.num.toString());
|
||||
resetUnread(node.num);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
text={node.user?.shortName ??
|
||||
t("unknown.shortName")}
|
||||
text={node.user?.shortName ?? t("unknown.shortName")}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
showFavorite={node.isFavorite}
|
||||
@@ -306,55 +317,56 @@ export const MessagesPage = () => {
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
label={`${
|
||||
t("page.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
chatName: isBroadcast && currentChannel
|
||||
label={`${t("page.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
chatName:
|
||||
isBroadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: isDirect && otherNode
|
||||
? (otherNode.user?.longName ?? t("unknown.longName"))
|
||||
: t("emptyState.title"),
|
||||
})
|
||||
}
|
||||
? (otherNode.user?.longName ?? t("unknown.longName"))
|
||||
: t("emptyState.title"),
|
||||
})}
|
||||
`}
|
||||
rightBar={rightSidebar}
|
||||
leftBar={leftSidebar}
|
||||
actions={isDirect && otherNode
|
||||
? [
|
||||
{
|
||||
key: "encryption",
|
||||
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
|
||||
iconClasses: otherNode.user?.publicKey?.length
|
||||
? "text-green-600"
|
||||
: "text-yellow-300",
|
||||
onClick() {
|
||||
toast({
|
||||
title: otherNode.user?.publicKey?.length
|
||||
? t("toast.messages.pkiEncryption.title")
|
||||
: t("toast.messages.pskEncryption.title"),
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
: []}
|
||||
actions={
|
||||
isDirect && otherNode
|
||||
? [
|
||||
{
|
||||
key: "encryption",
|
||||
icon: otherNode.user?.publicKey?.length
|
||||
? LockIcon
|
||||
: LockOpenIcon,
|
||||
iconClasses: otherNode.user?.publicKey?.length
|
||||
? "text-green-600"
|
||||
: "text-yellow-300",
|
||||
onClick() {
|
||||
toast({
|
||||
title: otherNode.user?.publicKey?.length
|
||||
? t("toast.messages.pkiEncryption.title")
|
||||
: t("toast.messages.pskEncryption.title"),
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{renderChatContent()}
|
||||
|
||||
<div className="flex-none dark:bg-slate-900 p-2">
|
||||
{(isBroadcast || isDirect)
|
||||
? (
|
||||
<MessageInput
|
||||
to={isDirect ? numericChatId : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="p-4 text-center text-slate-400 italic">
|
||||
{t("sendMessage.sendButton", { ns: "messages" })}
|
||||
</div>
|
||||
)}
|
||||
{isBroadcast || isDirect ? (
|
||||
<MessageInput
|
||||
to={isDirect ? numericChatId : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-slate-400 italic">
|
||||
{t("sendMessage.sendButton", { ns: "messages" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
|
||||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import {
|
||||
type DataRow,
|
||||
@@ -9,8 +12,13 @@ import {
|
||||
Table,
|
||||
} from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import useLang from "@core/hooks/useLang.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||
@@ -22,16 +30,8 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLang from "@core/hooks/useLang.ts";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
export interface DeleteNoteDialogProps {
|
||||
open: boolean;
|
||||
@@ -53,8 +53,8 @@ const NodesPage = (): JSX.Element => {
|
||||
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
|
||||
>();
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(() =>
|
||||
defaultFilterValues
|
||||
const [filterState, setFilterState] = useState<FilterState>(
|
||||
() => defaultFilterValues,
|
||||
);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
@@ -63,14 +63,6 @@ const NodesPage = (): JSX.Element => {
|
||||
[deferredFilterState, getNodes, nodeFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
|
||||
return () => {
|
||||
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const handleTraceroute = useCallback(
|
||||
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
|
||||
setSelectedTraceroute(traceroute);
|
||||
@@ -78,17 +70,11 @@ const NodesPage = (): JSX.Element => {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
return () => {
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const handleLocation = useCallback(
|
||||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
||||
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
||||
if (location.to.valueOf() !== hardware.myNodeNum) {
|
||||
return;
|
||||
}
|
||||
setSelectedLocation(location);
|
||||
},
|
||||
[hardware.myNodeNum],
|
||||
@@ -99,6 +85,26 @@ const NodesPage = (): JSX.Element => {
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
|
||||
return () => {
|
||||
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
|
||||
};
|
||||
}, [connection, handleTraceroute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
return () => {
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
};
|
||||
}, [connection, handleLocation]);
|
||||
|
||||
const tableHeadings: Heading[] = [
|
||||
{ title: "", sortable: false },
|
||||
{ title: t("nodesTable.headings.longName"), sortable: true },
|
||||
@@ -111,10 +117,11 @@ const NodesPage = (): JSX.Element => {
|
||||
];
|
||||
|
||||
const tableRows: DataRow[] = filteredNodes.map((node) => {
|
||||
const macAddress = base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? t("unknown.shortName");
|
||||
const macAddress =
|
||||
base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? t("unknown.shortName");
|
||||
|
||||
return {
|
||||
id: node.num,
|
||||
@@ -138,8 +145,6 @@ const NodesPage = (): JSX.Element => {
|
||||
evt.key === "Enter" && handleNodeInfoDialog(node.num);
|
||||
}}
|
||||
className="cursor-pointer underline ml-2 whitespace-break-spaces"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{node.user?.longName ?? numberToHexUnpadded(node.num)}
|
||||
</h1>
|
||||
@@ -153,10 +158,10 @@ const NodesPage = (): JSX.Element => {
|
||||
? node?.viaMqtt === false && node.hopsAway === 0
|
||||
? t("nodesTable.connectionStatus.direct")
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway ?? 0 > 1
|
||||
? t("unit.hop.plural")
|
||||
: t("unit.hops_one")
|
||||
} ${t("nodesTable.connectionStatus.away")}`
|
||||
(node.hopsAway ?? 0 > 1)
|
||||
? t("unit.hop.plural")
|
||||
: t("unit.hops_one")
|
||||
} ${t("nodesTable.connectionStatus.away")}`
|
||||
: t("nodesTable.connectionStatus.unknown")}
|
||||
{node?.viaMqtt === true
|
||||
? t("nodesTable.connectionStatus.viaMqtt")
|
||||
@@ -168,14 +173,14 @@ const NodesPage = (): JSX.Element => {
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.lastHeard === 0
|
||||
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
|
||||
: (
|
||||
<TimeAgo
|
||||
timestamp={node.lastHeard * 1000}
|
||||
locale={currentLanguage?.code}
|
||||
/>
|
||||
)}
|
||||
{node.lastHeard === 0 ? (
|
||||
<p>{t("nodesTable.lastHeardStatus.never")}</p>
|
||||
) : (
|
||||
<TimeAgo
|
||||
timestamp={node.lastHeard * 1000}
|
||||
locale={currentLanguage?.code}
|
||||
/>
|
||||
)}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.lastHeard,
|
||||
@@ -183,9 +188,11 @@ const NodesPage = (): JSX.Element => {
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
? <LockIcon className="text-green-600 mx-auto" />
|
||||
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon className="text-green-600 mx-auto" />
|
||||
) : (
|
||||
<LockOpenIcon className="text-yellow-300 mx-auto" />
|
||||
)}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: "", // Non-sortable column
|
||||
@@ -194,11 +201,8 @@ const NodesPage = (): JSX.Element => {
|
||||
content: (
|
||||
<Mono>
|
||||
{node.snr}
|
||||
{t("unit.dbm")}/
|
||||
{Math.min(
|
||||
Math.max((node.snr + 10) * 5, 0),
|
||||
100,
|
||||
)}%/{/* Percentage */}
|
||||
{t("unit.dbm")}/{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}
|
||||
%/{/* Percentage */}
|
||||
{(node.snr + 10) * 5}
|
||||
{t("unit.raw")}
|
||||
</Mono>
|
||||
@@ -207,9 +211,7 @@ const NodesPage = (): JSX.Element => {
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
</Mono>
|
||||
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>
|
||||
),
|
||||
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
|
||||
},
|
||||
@@ -222,61 +224,54 @@ const NodesPage = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
label=""
|
||||
leftBar={<Sidebar />}
|
||||
>
|
||||
<div className="pl-2 pt-2 flex flex-row">
|
||||
<div className="flex-1 mr-2">
|
||||
<Input
|
||||
placeholder={t("search.nodes")}
|
||||
value={filterState.nodeName}
|
||||
className="bg-transparent"
|
||||
showClearButton={!!filterState.nodeName}
|
||||
onChange={(e) =>
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
nodeName: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: "mr-1 p-2",
|
||||
showTextSearch: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
<Table
|
||||
headings={tableHeadings}
|
||||
rows={tableRows}
|
||||
/>
|
||||
<TracerouteResponseDialog
|
||||
traceroute={selectedTraceroute}
|
||||
open={!!selectedTraceroute}
|
||||
onOpenChange={() => setSelectedTraceroute(undefined)}
|
||||
/>
|
||||
<LocationResponseDialog
|
||||
location={selectedLocation}
|
||||
open={!!selectedLocation}
|
||||
onOpenChange={() => setSelectedLocation(undefined)}
|
||||
<PageLayout label="" leftBar={<Sidebar />}>
|
||||
<div className="pl-2 pt-2 flex flex-row">
|
||||
<div className="flex-1 mr-2">
|
||||
<Input
|
||||
placeholder={t("search.nodes")}
|
||||
value={filterState.nodeName}
|
||||
className="bg-transparent"
|
||||
showClearButton={!!filterState.nodeName}
|
||||
onChange={(e) =>
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
nodeName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</>
|
||||
<div className="flex justify-end">
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: "mr-1 p-2",
|
||||
showTextSearch: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
<Table headings={tableHeadings} rows={tableRows} />
|
||||
<TracerouteResponseDialog
|
||||
traceroute={selectedTraceroute}
|
||||
open={!!selectedTraceroute}
|
||||
onOpenChange={() => setSelectedTraceroute(undefined)}
|
||||
/>
|
||||
<LocationResponseDialog
|
||||
location={selectedLocation}
|
||||
open={!!selectedLocation}
|
||||
onOpenChange={() => setSelectedLocation(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user