Merge branch 'master' into feature/node-quick-options
This commit is contained in:
+1
-1
@@ -89,5 +89,5 @@
|
|||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4"
|
"packageManager": "pnpm@10.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
type MultiSelectFieldProps,
|
||||||
|
MultiSelectInput,
|
||||||
|
} from "@app/components/Form/FormMultiSelect";
|
||||||
import {
|
import {
|
||||||
GenericInput,
|
GenericInput,
|
||||||
type InputFieldProps,
|
type InputFieldProps,
|
||||||
@@ -19,6 +23,7 @@ import type { Control, FieldValues } from "react-hook-form";
|
|||||||
export type FieldProps<T> =
|
export type FieldProps<T> =
|
||||||
| InputFieldProps<T>
|
| InputFieldProps<T>
|
||||||
| SelectFieldProps<T>
|
| SelectFieldProps<T>
|
||||||
|
| MultiSelectFieldProps<T>
|
||||||
| ToggleFieldProps<T>
|
| ToggleFieldProps<T>
|
||||||
| PasswordGeneratorProps<T>;
|
| PasswordGeneratorProps<T>;
|
||||||
|
|
||||||
@@ -58,6 +63,8 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "multiSelect":
|
case "multiSelect":
|
||||||
return <div>tmp</div>;
|
return (
|
||||||
|
<MultiSelectInput field={field} control={control} disabled={disabled} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
BaseFormBuilderProps,
|
||||||
|
GenericFormElementProps,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
|
import type { FieldValues } from "react-hook-form";
|
||||||
|
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect";
|
||||||
|
|
||||||
|
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
|
type: "multiSelect";
|
||||||
|
placeholder?: string;
|
||||||
|
onValueChange: (name: string) => void;
|
||||||
|
isChecked: (name: string) => boolean;
|
||||||
|
value: string[];
|
||||||
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
|
enumValue: {
|
||||||
|
[s: string]: string | number;
|
||||||
|
};
|
||||||
|
formatEnumName?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelectInput<T extends FieldValues>({
|
||||||
|
field,
|
||||||
|
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||||
|
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||||
|
field.properties;
|
||||||
|
|
||||||
|
// Make sure to filter out the UNSET value, as it shouldn't be shown in the UI
|
||||||
|
const optionsEnumValues = enumValue
|
||||||
|
? Object.entries(enumValue)
|
||||||
|
.filter((value) => typeof value[1] === "number")
|
||||||
|
.filter((value) => value[0] !== "UNSET")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const formatName = (name: string) => {
|
||||||
|
if (!formatEnumName) return name;
|
||||||
|
return name
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelect {...remainingProperties}>
|
||||||
|
{optionsEnumValues.map(([name, value]) => (
|
||||||
|
<MultiSelectItem
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
value={value.toString()}
|
||||||
|
checked={field.isChecked(name)}
|
||||||
|
onCheckedChange={() => field.onValueChange(name)}
|
||||||
|
>
|
||||||
|
{formatEnumName ? formatName(name) : name}
|
||||||
|
</MultiSelectItem>
|
||||||
|
))}
|
||||||
|
</MultiSelect>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select" | "multiSelect";
|
type: "select";
|
||||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
enumValue: {
|
enumValue: {
|
||||||
[s: string]: string | number;
|
[s: string]: string | number;
|
||||||
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
{optionsEnumValues.map(([name, value]) => (
|
||||||
<SelectItem key={name + value} value={value.toString()}>
|
<SelectItem key={name} value={value.toString()}>
|
||||||
{formatEnumName
|
{formatEnumName
|
||||||
? name
|
? name
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
|
import {
|
||||||
|
type FlagName,
|
||||||
|
usePositionFlags,
|
||||||
|
} from "@app/core/hooks/usePositionFlags";
|
||||||
import type { PositionValidation } from "@app/validation/config/position.tsx";
|
import type { PositionValidation } from "@app/validation/config/position.tsx";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/js";
|
import { Protobuf } from "@meshtastic/js";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export const Position = (): JSX.Element => {
|
export const Position = () => {
|
||||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
|
config.position.positionFlags ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
setWorkingConfig(
|
return setWorkingConfig(
|
||||||
new Protobuf.Config.Config({
|
new Protobuf.Config.Config({
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "position",
|
case: "position",
|
||||||
value: data,
|
value: { ...data, positionFlags: flagsValue },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPositonFlagChange = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
return toggleFlag(name as FlagName);
|
||||||
|
},
|
||||||
|
[toggleFlag],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<PositionValidation>
|
<DynamicForm<PositionValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={(data) => {
|
||||||
|
data.positionFlags = flagsValue;
|
||||||
|
return onSubmit(data);
|
||||||
|
}}
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -53,10 +71,16 @@ export const Position = (): JSX.Element => {
|
|||||||
{
|
{
|
||||||
type: "multiSelect",
|
type: "multiSelect",
|
||||||
name: "positionFlags",
|
name: "positionFlags",
|
||||||
|
value: activeFlags,
|
||||||
|
isChecked: (name: string) =>
|
||||||
|
activeFlags.includes(name as FlagName),
|
||||||
|
onValueChange: onPositonFlagChange,
|
||||||
label: "Position Flags",
|
label: "Position Flags",
|
||||||
description: "Configuration options for Position messages",
|
placeholder: "Select position flags...",
|
||||||
|
description:
|
||||||
|
"Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.",
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags,
|
enumValue: getAllFlags(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
|||||||
window.location.hostname,
|
window.location.hostname,
|
||||||
)
|
)
|
||||||
? "meshtastic.local"
|
? "meshtastic.local"
|
||||||
: window.location.hostname,
|
: window.location.host,
|
||||||
tls: location.protocol === "https:",
|
tls: location.protocol === "https:",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Separator } from "@app/components/UI/Seperator";
|
import { Separator } from "@app/components/UI/Seperator";
|
||||||
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
||||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||||
|
import { formatQuantity } from "@app/core/utils/string";
|
||||||
import { Avatar } from "@components/UI/Avatar";
|
import { Avatar } from "@components/UI/Avatar";
|
||||||
import { Mono } from "@components/generic/Mono.tsx";
|
import { Mono } from "@components/generic/Mono.tsx";
|
||||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||||
@@ -30,7 +31,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
].replaceAll("_", " ");
|
].replaceAll("_", " ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark:text-black">
|
<div className="dark:text-black p-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||||
<Avatar text={node.user?.shortName} />
|
<Avatar text={node.user?.shortName} />
|
||||||
@@ -132,7 +133,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
className="ml-2 mr-1"
|
className="ml-2 mr-1"
|
||||||
aria-label="Elevation"
|
aria-label="Elevation"
|
||||||
/>
|
/>
|
||||||
<div>{node.position?.altitude} ft</div>
|
<div>
|
||||||
|
{formatQuantity(node.position?.altitude, {
|
||||||
|
one: "meter",
|
||||||
|
other: "meters",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,74 +1,87 @@
|
|||||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
|
||||||
import {
|
import {
|
||||||
type MessageWithState,
|
type MessageWithState,
|
||||||
useDevice,
|
useDevice,
|
||||||
} from "@app/core/stores/deviceStore.ts";
|
} from "@app/core/stores/deviceStore.ts";
|
||||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
||||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
import type { Types } from "@meshtastic/js";
|
||||||
import type { Protobuf, Types } from "@meshtastic/js";
|
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
export interface ChannelChatProps {
|
export interface ChannelChatProps {
|
||||||
messages?: MessageWithState[];
|
messages?: MessageWithState[];
|
||||||
channel: Types.ChannelNumber;
|
channel: Types.ChannelNumber;
|
||||||
to: Types.Destination;
|
to: Types.Destination;
|
||||||
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<div className="flex flex-col place-content-center place-items-center p-8 text-white">
|
||||||
|
<InboxIcon className="h-8 w-8 mb-2" />
|
||||||
|
<span className="text-sm">No Messages</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const ChannelChat = ({
|
export const ChannelChat = ({
|
||||||
messages,
|
messages,
|
||||||
channel,
|
channel,
|
||||||
to,
|
to,
|
||||||
traceroutes,
|
|
||||||
}: ChannelChatProps): JSX.Element => {
|
}: ChannelChatProps): JSX.Element => {
|
||||||
const { nodes } = useDevice();
|
const { nodes } = useDevice();
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current;
|
||||||
|
if (scrollContainer) {
|
||||||
|
const isNearBottom =
|
||||||
|
scrollContainer.scrollHeight -
|
||||||
|
scrollContainer.scrollTop -
|
||||||
|
scrollContainer.clientHeight <
|
||||||
|
100;
|
||||||
|
if (isNearBottom) {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
|
if (!messages?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full container mx-auto">
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<EmptyState />
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 p-4 w-full dark:bg-gray-900">
|
||||||
|
<MessageInput to={to} channel={channel} maxBytes={200} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-col">
|
<div className="flex flex-col h-full w-full container mx-auto">
|
||||||
<div className="flex flex-grow">
|
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||||
<div className="flex flex-grow flex-col">
|
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
|
||||||
{messages ? (
|
{messages.map((message, index) => {
|
||||||
messages.map((message, index) => (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
lastMsgSameUser={
|
|
||||||
index === 0
|
|
||||||
? false
|
|
||||||
: messages[index - 1].from === message.from
|
|
||||||
}
|
|
||||||
sender={nodes.get(message.from)}
|
sender={nodes.get(message.from)}
|
||||||
|
lastMsgSameUser={
|
||||||
|
index > 0 && messages[index - 1].from === message.from
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))
|
);
|
||||||
) : (
|
})}
|
||||||
<div className="m-auto">
|
<div ref={messagesEndRef} className="w-full" />
|
||||||
<InboxIcon className="m-auto" />
|
|
||||||
<Subtle>No Messages</Subtle>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex flex-grow flex-col border-slate-400 border-l ${traceroutes === undefined ? "hidden" : ""}`}
|
|
||||||
>
|
|
||||||
{to === "broadcast" ? null : traceroutes ? (
|
|
||||||
traceroutes.map((traceroute, index) => (
|
|
||||||
<TraceRoute
|
|
||||||
key={traceroute.id}
|
|
||||||
from={nodes.get(traceroute.from)}
|
|
||||||
to={nodes.get(traceroute.to)}
|
|
||||||
route={traceroute.data.route}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="m-auto">
|
|
||||||
<InboxIcon className="m-auto" />
|
|
||||||
<Subtle>No Traceroutes</Subtle>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-3 pr-3 pt-3 pb-1">
|
<div className="flex-shrink-0 mt-2 p-4 w-full dark:bg-gray-900">
|
||||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
<MessageInput to={to} channel={channel} maxBytes={200} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,73 +1,168 @@
|
|||||||
import type { MessageWithState } from "@app/core/stores/deviceStore.ts";
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipArrow,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@app/components/UI/Tooltip";
|
||||||
|
import { useAppStore } from "@app/core/stores/appStore";
|
||||||
|
import {
|
||||||
|
type MessageWithState,
|
||||||
|
useDeviceStore,
|
||||||
|
} from "@app/core/stores/deviceStore.ts";
|
||||||
|
import { cn } from "@app/core/utils/cn";
|
||||||
import { Avatar } from "@components/UI/Avatar";
|
import { Avatar } from "@components/UI/Avatar";
|
||||||
import type { Protobuf } from "@meshtastic/js";
|
import type { Protobuf } from "@meshtastic/js";
|
||||||
import {
|
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||||
AlertCircleIcon,
|
import type { LucideIcon } from "lucide-react";
|
||||||
CheckCircle2Icon,
|
import { useMemo } from "react";
|
||||||
CircleEllipsisIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export interface MessageProps {
|
const MESSAGE_STATES = {
|
||||||
|
ACK: "ack",
|
||||||
|
WAITING: "waiting",
|
||||||
|
FAILED: "failed",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type MessageState = MessageWithState["state"];
|
||||||
|
|
||||||
|
interface MessageProps {
|
||||||
lastMsgSameUser: boolean;
|
lastMsgSameUser: boolean;
|
||||||
message: MessageWithState;
|
message: MessageWithState;
|
||||||
sender?: Protobuf.Mesh.NodeInfo;
|
sender: Protobuf.Mesh.NodeInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
interface StatusTooltipProps {
|
||||||
return lastMsgSameUser ? (
|
state: MessageState;
|
||||||
<div className="ml-5 flex">
|
children: React.ReactNode;
|
||||||
{message.state === "ack" ? (
|
}
|
||||||
<CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
|
|
||||||
) : message.state === "waiting" ? (
|
interface StatusIconProps {
|
||||||
<CircleEllipsisIcon size={16} className="my-auto text-textSecondary" />
|
state: MessageState;
|
||||||
) : (
|
className?: string;
|
||||||
<AlertCircleIcon size={16} className="my-auto text-textSecondary" />
|
}
|
||||||
)}
|
|
||||||
<span
|
const STATUS_TEXT_MAP: Record<MessageState, string> = {
|
||||||
className={`ml-4 border-l-2 border-l-backgroundPrimary pl-2 ${
|
[MESSAGE_STATES.ACK]: "Message delivered",
|
||||||
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
|
[MESSAGE_STATES.WAITING]: "Waiting for delivery",
|
||||||
}`}
|
[MESSAGE_STATES.FAILED]: "Delivery failed",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
|
||||||
|
[MESSAGE_STATES.ACK]: CheckCircle2,
|
||||||
|
[MESSAGE_STATES.WAITING]: CircleEllipsis,
|
||||||
|
[MESSAGE_STATES.FAILED]: AlertCircle,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state];
|
||||||
|
|
||||||
|
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
|
sideOffset={5}
|
||||||
>
|
>
|
||||||
{message.data}
|
{getStatusText(state)}
|
||||||
</span>
|
<TooltipArrow className="fill-slate-800" />
|
||||||
</div>
|
</TooltipContent>
|
||||||
) : (
|
</Tooltip>
|
||||||
<div className="mx-4 mt-2 gap-2">
|
</TooltipProvider>
|
||||||
<div className="flex gap-2">
|
);
|
||||||
<div className="w-6 cursor-pointer">
|
|
||||||
<Avatar text={sender?.user?.shortName ?? "UNK"} />
|
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||||
</div>
|
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||||
<span className="cursor-pointer font-medium text-textPrimary">
|
const iconClass = cn(
|
||||||
{sender?.user?.longName ?? "UNK"}
|
className,
|
||||||
</span>
|
"text-gray-500 dark:text-gray-400 w-4 h-4 flex-shrink-0",
|
||||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
);
|
||||||
{message.rxTime.toLocaleDateString()}
|
|
||||||
</span>
|
const Icon = STATUS_ICON_MAP[state];
|
||||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
return (
|
||||||
{message.rxTime.toLocaleTimeString(undefined, {
|
<StatusTooltip state={state}>
|
||||||
hour: "2-digit",
|
<Icon
|
||||||
minute: "2-digit",
|
className={iconClass}
|
||||||
})}
|
{...otherProps}
|
||||||
</span>
|
color={isFailed ? "red" : "currentColor"}
|
||||||
</div>
|
/>
|
||||||
<div className="ml-1 flex">
|
</StatusTooltip>
|
||||||
{message.state === "ack" ? (
|
);
|
||||||
<CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
|
};
|
||||||
) : message.state === "waiting" ? (
|
|
||||||
<CircleEllipsisIcon
|
const getMessageTextStyles = (state: MessageState) => {
|
||||||
size={16}
|
const isAcknowledged = state === MESSAGE_STATES.ACK;
|
||||||
className="my-auto text-textSecondary"
|
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||||
/>
|
const isWaiting = state === MESSAGE_STATES.WAITING;
|
||||||
) : (
|
|
||||||
<AlertCircleIcon size={16} className="my-auto text-textSecondary" />
|
return cn(
|
||||||
|
"break-words overflow-hidden",
|
||||||
|
isAcknowledged
|
||||||
|
? "text-black dark:text-white"
|
||||||
|
: "text-black dark:text-gray-400",
|
||||||
|
isFailed && "text-red-500 dark:text-red-500",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeDisplay = ({
|
||||||
|
date,
|
||||||
|
className,
|
||||||
|
}: { date: Date; className?: string }) => (
|
||||||
|
<div className={cn("flex items-center gap-2 flex-shrink-0", className)}>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
{date.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
{date.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||||
|
const { getDevices } = useDeviceStore();
|
||||||
|
|
||||||
|
const isDeviceUser = useMemo(
|
||||||
|
() =>
|
||||||
|
getDevices()
|
||||||
|
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||||
|
.includes(message.from),
|
||||||
|
[getDevices, message.from],
|
||||||
|
);
|
||||||
|
const messageUser = sender?.user;
|
||||||
|
|
||||||
|
const messageTextClass = getMessageTextStyles(message.state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full px-4 justify-start">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col flex-wrap items-start py-1",
|
||||||
|
isDeviceUser && "items-end",
|
||||||
)}
|
)}
|
||||||
<span
|
>
|
||||||
className={`ml-4 border-l-2 border-l-backgroundPrimary pl-2 ${
|
<div className="flex items-center gap-2 mb-2">
|
||||||
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
|
{!lastMsgSameUser ? (
|
||||||
}`}
|
<div className="flex place-items-center gap-2 mb-1">
|
||||||
>
|
<Avatar text={messageUser?.shortName} />
|
||||||
{message.data}
|
<div className="flex flex-col">
|
||||||
</span>
|
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{messageUser?.longName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<TimeDisplay date={message.rxTime} />
|
||||||
|
<div className="flex place-items-center gap-2 pb-2">
|
||||||
|
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
|
||||||
|
{message.data}
|
||||||
|
</div>
|
||||||
|
<StatusIcon state={message.state} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Input } from "@components/UI/Input.tsx";
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Types } from "@meshtastic/js";
|
import type { Types } from "@meshtastic/js";
|
||||||
import { SendIcon } from "lucide-react";
|
import { SendIcon } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import {
|
||||||
|
type JSX,
|
||||||
|
startTransition,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
to: Types.Destination;
|
to: Types.Destination;
|
||||||
@@ -26,7 +32,7 @@ export const MessageInput = ({
|
|||||||
} = useDevice();
|
} = useDevice();
|
||||||
const myNodeNum = hardware.myNodeNum;
|
const myNodeNum = hardware.myNodeNum;
|
||||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||||
const [messageBytes, setMessageBytes] = useState(maxBytes);
|
const [messageBytes, setMessageBytes] = useState(0);
|
||||||
|
|
||||||
const debouncedSetMessageDraft = useMemo(
|
const debouncedSetMessageDraft = useMemo(
|
||||||
() => debounce(setMessageDraft, 300),
|
() => debounce(setMessageDraft, 300),
|
||||||
@@ -64,10 +70,11 @@ export const MessageInput = ({
|
|||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
const byteLength = new Blob([newValue]).size;
|
const byteLength = new Blob([newValue]).size;
|
||||||
|
|
||||||
if (byteLength <= maxBytes) {
|
if (byteLength <= maxBytes) {
|
||||||
setLocalDraft(newValue);
|
setLocalDraft(newValue);
|
||||||
debouncedSetMessageDraft(newValue);
|
debouncedSetMessageDraft(newValue);
|
||||||
setMessageBytes(maxBytes - byteLength);
|
setMessageBytes(byteLength);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,11 +82,16 @@ export const MessageInput = ({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<form
|
<form
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onSubmit={(e) => {
|
action={async (formData: FormData) => {
|
||||||
e.preventDefault();
|
// prevent user from sending blank/empty message
|
||||||
sendText(localDraft);
|
if (localDraft === "") return;
|
||||||
setLocalDraft("");
|
const message = formData.get("messageInput") as string;
|
||||||
setMessageDraft("");
|
startTransition(() => {
|
||||||
|
sendText(message);
|
||||||
|
setLocalDraft("");
|
||||||
|
setMessageDraft("");
|
||||||
|
setMessageBytes(0);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-grow gap-2">
|
<div className="flex flex-grow gap-2">
|
||||||
@@ -87,14 +99,16 @@ export const MessageInput = ({
|
|||||||
<Input
|
<Input
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
minLength={1}
|
minLength={1}
|
||||||
|
name="messageInput"
|
||||||
placeholder="Enter Message"
|
placeholder="Enter Message"
|
||||||
value={localDraft}
|
value={localDraft}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center w-24 p-2 place-content-end">
|
||||||
{messageBytes}/{maxBytes}
|
{messageBytes}/{maxBytes}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
<SendIcon size={16} />
|
<SendIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const buttonVariants = cva(
|
|||||||
success:
|
success:
|
||||||
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
|
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
|
||||||
outline:
|
outline:
|
||||||
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
|
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-100",
|
||||||
subtle:
|
subtle:
|
||||||
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
|
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
|||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className={`flex flex- justify-center p-2 ${className}`}
|
className={`flex mt-auto justify-center p-2 ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--backgroundPrimary)",
|
backgroundColor: "var(--backgroundPrimary)",
|
||||||
color: "var(--textPrimary)",
|
color: "var(--textPrimary)",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { cn } from "@app/core/utils/cn";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelect = ({ children, className = "" }: MultiSelectProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-wrap gap-2", className)}>{children}</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MultiSelectItemProps {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
checked: boolean;
|
||||||
|
onCheckedChange: (name: string, value: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectItem = ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: MultiSelectItemProps) => {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
name={name}
|
||||||
|
id={value}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(val) => onCheckedChange(name, !!val)}
|
||||||
|
className={cn(
|
||||||
|
`
|
||||||
|
inline-flex items-center rounded-md px-3 py-2 text-sm transition-colors
|
||||||
|
border border-slate-300
|
||||||
|
hover:bg-slate-100 dark:hover:bg-slate-800
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2
|
||||||
|
data-[state=checked]:bg-slate-100 dark:data-[state=checked]:bg-slate-700`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="mr-2">
|
||||||
|
<Check className="h-4 w-4 animate-in zoom-in duration-200" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
{children}
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MultiSelect, MultiSelectItem };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
export interface SidebarButtonProps {
|
export interface SidebarButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,10 +21,10 @@ export const SidebarButton = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
variant={active ? "subtle" : "ghost"}
|
variant={active ? "subtle" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-2"
|
className="flex gap-2 w-full"
|
||||||
>
|
>
|
||||||
{Icon && <Icon size={16} />}
|
{Icon && <Icon size={16} />}
|
||||||
{element && element}
|
{element && element}
|
||||||
{label}
|
<span className="flex flex-1 justify-start flex-shrink-0">{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const toastVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
|
"border bg-backgroundPrimary text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
|
||||||
destructive:
|
destructive:
|
||||||
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
|
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const Tooltip = ({ ...props }) => <TooltipPrimitive.Root {...props} />;
|
|||||||
Tooltip.displayName = TooltipPrimitive.Tooltip.displayName;
|
Tooltip.displayName = TooltipPrimitive.Tooltip.displayName;
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
const TooltipArrow = TooltipPrimitive.Arrow;
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
const TooltipContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
@@ -26,4 +27,10 @@ const TooltipContent = React.forwardRef<
|
|||||||
));
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
export {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipArrow,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const FLAGS = {
|
||||||
|
UNSET: 0,
|
||||||
|
Altitude: 1,
|
||||||
|
"Altitude is Mean Sea Level": 2,
|
||||||
|
"Altitude Geoidal Seperation": 4,
|
||||||
|
"Dilution of precision (DOP) PDOP used by default": 8,
|
||||||
|
"If DOP is set, use HDOP / VDOP values instead of PDOP": 16,
|
||||||
|
"Number of satellites": 32,
|
||||||
|
"Sequence number": 64,
|
||||||
|
Timestamp: 128,
|
||||||
|
"Vehicle heading": 256,
|
||||||
|
"Vehicle speed": 512,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type FlagName = keyof typeof FLAGS;
|
||||||
|
type FlagsObject = typeof FLAGS;
|
||||||
|
|
||||||
|
type UsePositionFlagsProps = {
|
||||||
|
decode: (value: number) => FlagName[];
|
||||||
|
encode: (flagNames: FlagName[]) => number;
|
||||||
|
hasFlag: (value: number, flagName: FlagName) => boolean;
|
||||||
|
getAllFlags: () => FlagsObject;
|
||||||
|
isValidValue: (value: number) => boolean;
|
||||||
|
flagsValue: number;
|
||||||
|
activeFlags: FlagName[];
|
||||||
|
toggleFlag: (flagName: FlagName) => void;
|
||||||
|
setFlag: (flagName: FlagName, enabled: boolean) => void;
|
||||||
|
setFlags: (value: number) => void;
|
||||||
|
clearFlags: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
|
||||||
|
const [flagsValue, setFlagsValue] = useState<number>(initialValue);
|
||||||
|
|
||||||
|
const utils = useMemo(() => {
|
||||||
|
const decode = (value: number): FlagName[] => {
|
||||||
|
if (value === 0) return ["UNSET"];
|
||||||
|
|
||||||
|
const activeFlags: FlagName[] = [];
|
||||||
|
for (const [name, flagValue] of Object.entries(FLAGS)) {
|
||||||
|
if (flagValue !== 0 && (value & flagValue) === flagValue) {
|
||||||
|
activeFlags.push(name as FlagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return activeFlags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const encode = (flagNames: FlagName[]): number => {
|
||||||
|
if (flagNames.includes("UNSET")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return flagNames.reduce((acc, name) => {
|
||||||
|
const value = FLAGS[name];
|
||||||
|
return acc | value;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFlag = (value: number, flagName: FlagName): boolean => {
|
||||||
|
const flagValue = FLAGS[flagName];
|
||||||
|
return (value & flagValue) === flagValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllFlags = (): FlagsObject => {
|
||||||
|
return FLAGS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidValue = (value: number): boolean => {
|
||||||
|
const maxValue = Object.values(FLAGS)
|
||||||
|
.filter((val) => val !== 0) // Exclude UNSET (0) from the calculation
|
||||||
|
.reduce((acc, val) => acc | val, 0);
|
||||||
|
return Number.isInteger(value) && value >= 0 && value <= maxValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
decode,
|
||||||
|
encode,
|
||||||
|
hasFlag,
|
||||||
|
getAllFlags,
|
||||||
|
isValidValue,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFlag = useCallback((flagName: FlagName) => {
|
||||||
|
const flagValue = FLAGS[flagName];
|
||||||
|
setFlagsValue((prev) => prev ^ flagValue);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
|
||||||
|
const flagValue = FLAGS[flagName];
|
||||||
|
setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFlags = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
if (!utils.isValidValue(value)) {
|
||||||
|
throw new Error(`Invalid flags value: ${value}`);
|
||||||
|
}
|
||||||
|
setFlagsValue(value);
|
||||||
|
},
|
||||||
|
[utils],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFlags = useCallback(() => {
|
||||||
|
setFlagsValue(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeFlags = utils.decode(flagsValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...utils,
|
||||||
|
flagsValue,
|
||||||
|
activeFlags,
|
||||||
|
toggleFlag,
|
||||||
|
setFlag,
|
||||||
|
setFlags,
|
||||||
|
clearFlags,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
interface PluralForms {
|
||||||
|
one: string;
|
||||||
|
other: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormatOptions {
|
||||||
|
locale?: string;
|
||||||
|
pluralRules?: Intl.PluralRulesOptions;
|
||||||
|
numberFormat?: Intl.NumberFormatOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuantity(
|
||||||
|
value: number,
|
||||||
|
forms: PluralForms,
|
||||||
|
options: FormatOptions = {},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
locale = "en-US",
|
||||||
|
pluralRules: pluralOptions = { type: "cardinal" },
|
||||||
|
numberFormat: numberOptions = {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const pluralRules = new Intl.PluralRules(locale, pluralOptions);
|
||||||
|
const numberFormat = new Intl.NumberFormat(locale, numberOptions);
|
||||||
|
|
||||||
|
const pluralCategory = pluralRules.select(value);
|
||||||
|
const word = forms[pluralCategory];
|
||||||
|
|
||||||
|
return `${numberFormat.format(value)} ${word}`;
|
||||||
|
}
|
||||||
+103
-126
@@ -1,59 +1,93 @@
|
|||||||
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
|
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
|
||||||
import { Avatar } from "@app/components/UI/Avatar";
|
import { Avatar } from "@app/components/UI/Avatar";
|
||||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
|
||||||
import { cn } from "@app/core/utils/cn.ts";
|
|
||||||
import { PageLayout } from "@components/PageLayout.tsx";
|
import { PageLayout } from "@components/PageLayout.tsx";
|
||||||
import { Sidebar } from "@components/Sidebar.tsx";
|
import { Sidebar } from "@components/Sidebar.tsx";
|
||||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
|
||||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Protobuf } from "@meshtastic/js";
|
import type { Protobuf } from "@meshtastic/js";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
|
||||||
import { bbox, lineString } from "@turf/turf";
|
import { bbox, lineString } from "@turf/turf";
|
||||||
import {
|
import { MapPinIcon } from "lucide-react";
|
||||||
BoxSelectIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
ZoomInIcon,
|
|
||||||
ZoomOutIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
|
import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { AttributionControl, Marker, Popup, useMap } from "react-map-gl";
|
import {
|
||||||
|
AttributionControl,
|
||||||
|
GeolocateControl,
|
||||||
|
Marker,
|
||||||
|
NavigationControl,
|
||||||
|
Popup,
|
||||||
|
ScaleControl,
|
||||||
|
useMap,
|
||||||
|
} from "react-map-gl";
|
||||||
import MapGl from "react-map-gl/maplibre";
|
import MapGl from "react-map-gl/maplibre";
|
||||||
|
|
||||||
|
type NodePosition = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertToLatLng = (position: {
|
||||||
|
latitudeI?: number;
|
||||||
|
longitudeI?: number;
|
||||||
|
}): NodePosition => ({
|
||||||
|
latitude: (position.latitudeI ?? 0) / 1e7,
|
||||||
|
longitude: (position.longitudeI ?? 0) / 1e7,
|
||||||
|
});
|
||||||
|
|
||||||
const MapPage = (): JSX.Element => {
|
const MapPage = (): JSX.Element => {
|
||||||
const { nodes, waypoints } = useDevice();
|
const { nodes, waypoints } = useDevice();
|
||||||
const { rasterSources, darkMode } = useAppStore();
|
const { darkMode } = useAppStore();
|
||||||
const { default: map } = useMap();
|
const { default: map } = useMap();
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(0);
|
|
||||||
const [selectedNode, setSelectedNode] =
|
const [selectedNode, setSelectedNode] =
|
||||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||||
|
|
||||||
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
// Filter out nodes without a valid position
|
||||||
|
const validNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(nodes.values()).filter(
|
||||||
|
(node): node is Protobuf.Mesh.NodeInfo =>
|
||||||
|
Boolean(node.position?.latitudeI),
|
||||||
|
),
|
||||||
|
[nodes],
|
||||||
|
);
|
||||||
|
|
||||||
const getBBox = useCallback(() => {
|
const handleMarkerClick = useCallback(
|
||||||
|
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
||||||
|
event?.originalEvent?.stopPropagation();
|
||||||
|
|
||||||
|
setSelectedNode(node);
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
const position = convertToLatLng(node.position);
|
||||||
|
map.easeTo({
|
||||||
|
center: [position.longitude, position.latitude],
|
||||||
|
zoom: map?.getZoom(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[map],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the bounds of the map based on the nodes furtherest away from center
|
||||||
|
const getMapBounds = useCallback(() => {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nodesWithPosition = allNodes.filter(
|
|
||||||
(node) => node.position?.latitudeI,
|
if (!validNodes.length) {
|
||||||
);
|
|
||||||
if (!nodesWithPosition.length) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nodesWithPosition.length === 1) {
|
if (validNodes.length === 1) {
|
||||||
map.easeTo({
|
map.easeTo({
|
||||||
zoom: 12,
|
zoom: map.getZoom(),
|
||||||
center: [
|
center: [
|
||||||
(nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7,
|
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
|
||||||
(nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7,
|
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = lineString(
|
const line = lineString(
|
||||||
nodesWithPosition.map((n) => [
|
validNodes.map((n) => [
|
||||||
(n.position?.latitudeI ?? 0) / 1e7,
|
(n.position?.latitudeI ?? 0) / 1e7,
|
||||||
(n.position?.longitudeI ?? 0) / 1e7,
|
(n.position?.longitudeI ?? 0) / 1e7,
|
||||||
]),
|
]),
|
||||||
@@ -69,78 +103,54 @@ const MapPage = (): JSX.Element => {
|
|||||||
if (center) {
|
if (center) {
|
||||||
map.easeTo(center);
|
map.easeTo(center);
|
||||||
}
|
}
|
||||||
}, [allNodes, map]);
|
}, [validNodes, map]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Generate all markers
|
||||||
map?.on("zoom", () => {
|
const markers = useMemo(
|
||||||
setZoom(map?.getZoom() ?? 0);
|
() =>
|
||||||
});
|
validNodes.map((node) => {
|
||||||
}, [map]);
|
const position = convertToLatLng(node.position);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`marker-${node.num}`}
|
||||||
|
longitude={position.longitude}
|
||||||
|
latitude={position.latitude}
|
||||||
|
anchor="bottom"
|
||||||
|
onClick={(e) => handleMarkerClick(node, e)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
text={node.user?.shortName?.toString() ?? node.num.toString()}
|
||||||
|
className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600"
|
||||||
|
/>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[validNodes, handleMarkerClick],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
map?.on("load", () => {
|
map?.on("load", () => {
|
||||||
getBBox();
|
getMapBounds();
|
||||||
});
|
});
|
||||||
}, [map, getBBox]);
|
}, [map, getMapBounds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar>
|
<Sidebar />
|
||||||
<SidebarSection label="Sources">
|
<PageLayout label="Map" noPadding={true} actions={[]}>
|
||||||
{rasterSources.map((source) => (
|
|
||||||
<SidebarButton key={source.title} label={source.title} />
|
|
||||||
))}
|
|
||||||
</SidebarSection>
|
|
||||||
</Sidebar>
|
|
||||||
<PageLayout
|
|
||||||
label="Map"
|
|
||||||
noPadding={true}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
icon: ZoomInIcon,
|
|
||||||
onClick() {
|
|
||||||
map?.zoomIn();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ZoomOutIcon,
|
|
||||||
onClick() {
|
|
||||||
map?.zoomOut();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: BoxSelectIcon,
|
|
||||||
onClick() {
|
|
||||||
getBBox();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MapGl
|
<MapGl
|
||||||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||||
// onClick={(e) => {
|
|
||||||
// const waypoint = new Protobuf.Waypoint({
|
|
||||||
// name: "test",
|
|
||||||
// description: "test description",
|
|
||||||
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
|
|
||||||
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
|
|
||||||
// });
|
|
||||||
// addWaypoint(waypoint);
|
|
||||||
// connection?.sendWaypoint(waypoint, "broadcast");
|
|
||||||
// }}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
|
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
renderWorldCopies={false}
|
renderWorldCopies={false}
|
||||||
maxPitch={0}
|
maxPitch={0}
|
||||||
|
antialias={true}
|
||||||
style={{
|
style={{
|
||||||
filter: darkMode ? "brightness(0.8)" : "",
|
filter: darkMode ? "brightness(0.9)" : "",
|
||||||
}}
|
}}
|
||||||
dragRotate={false}
|
dragRotate={false}
|
||||||
touchZoomRotate={false}
|
touchZoomRotate={false}
|
||||||
initialViewState={{
|
initialViewState={{
|
||||||
zoom: 1.6,
|
zoom: 1.8,
|
||||||
latitude: 35,
|
latitude: 35,
|
||||||
longitude: 0,
|
longitude: 0,
|
||||||
}}
|
}}
|
||||||
@@ -151,6 +161,14 @@ const MapPage = (): JSX.Element => {
|
|||||||
color: darkMode ? "black" : "",
|
color: darkMode ? "black" : "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<GeolocateControl
|
||||||
|
position="top-right"
|
||||||
|
positionOptions={{ enableHighAccuracy: true }}
|
||||||
|
trackUserLocation
|
||||||
|
/>
|
||||||
|
<NavigationControl position="top-right" showCompass={false} />
|
||||||
|
|
||||||
|
<ScaleControl />
|
||||||
{waypoints.map((wp) => (
|
{waypoints.map((wp) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={wp.id}
|
key={wp.id}
|
||||||
@@ -163,58 +181,17 @@ const MapPage = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
{/* {rasterSources.map((source, index) => (
|
{markers}
|
||||||
<Source key={index} type="raster" {...source}>
|
{selectedNode ? (
|
||||||
<Layer type="raster" />
|
|
||||||
</Source>
|
|
||||||
))} */}
|
|
||||||
{allNodes.map((node) => {
|
|
||||||
if (node.position?.latitudeI && node.num !== selectedNode?.num) {
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
key={node.num}
|
|
||||||
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
|
||||||
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
|
||||||
// style={{ filter: darkMode ? "invert(1)" : "" }}
|
|
||||||
anchor="bottom"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedNode(node);
|
|
||||||
map?.easeTo({
|
|
||||||
zoom: 12,
|
|
||||||
center: [
|
|
||||||
(node.position?.longitudeI ?? 0) / 1e7,
|
|
||||||
(node.position?.latitudeI ?? 0) / 1e7,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex cursor-pointer gap-2 rounded-md bg-transparent p-1.5">
|
|
||||||
<Avatar
|
|
||||||
text={
|
|
||||||
node.user?.shortName.toString() ?? node.num.toString()
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
|
||||||
{node.user?.longName ||
|
|
||||||
`!${numberToHexUnpadded(node.num)}`}
|
|
||||||
</Subtle>
|
|
||||||
</div>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
{selectedNode?.position && (
|
|
||||||
<Popup
|
<Popup
|
||||||
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7}
|
anchor="top"
|
||||||
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7}
|
longitude={convertToLatLng(selectedNode.position).longitude}
|
||||||
anchor="left"
|
latitude={convertToLatLng(selectedNode.position).latitude}
|
||||||
closeOnClick={false}
|
|
||||||
onClose={() => setSelectedNode(null)}
|
onClose={() => setSelectedNode(null)}
|
||||||
>
|
>
|
||||||
<NodeDetail node={selectedNode} />
|
<NodeDetail node={selectedNode} />
|
||||||
</Popup>
|
</Popup>
|
||||||
)}
|
) : null}
|
||||||
</MapGl>
|
</MapGl>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -117,21 +117,6 @@ export const MessagesPage = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: WaypointsIcon,
|
|
||||||
async onClick() {
|
|
||||||
const targetNode = nodes.get(activeChat)?.num;
|
|
||||||
if (targetNode === undefined) return;
|
|
||||||
toast({
|
|
||||||
title: "Sending Traceroute, please wait...",
|
|
||||||
});
|
|
||||||
await connection?.traceRoute(targetNode).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Traceroute sent.",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
@@ -155,7 +140,6 @@ export const MessagesPage = () => {
|
|||||||
to={activeChat}
|
to={activeChat}
|
||||||
messages={messages.direct.get(node.num)}
|
messages={messages.direct.get(node.num)}
|
||||||
channel={Types.ChannelNumber.Primary}
|
channel={Types.ChannelNumber.Primary}
|
||||||
traceroutes={traceroutes.get(node.num)}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-11
@@ -3,6 +3,8 @@ import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog";
|
|||||||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog";
|
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog";
|
||||||
import Footer from "@app/components/UI/Footer";
|
import Footer from "@app/components/UI/Footer";
|
||||||
import { Sidebar } from "@components/Sidebar.tsx";
|
import { Sidebar } from "@components/Sidebar.tsx";
|
||||||
|
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||||
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Mono } from "@components/generic/Mono.tsx";
|
import { Mono } from "@components/generic/Mono.tsx";
|
||||||
import { Table } from "@components/generic/Table/index.tsx";
|
import { Table } from "@components/generic/Table/index.tsx";
|
||||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||||
@@ -94,10 +96,9 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{ title: "Connection", type: "normal", sortable: true },
|
{ title: "Connection", type: "normal", sortable: true },
|
||||||
]}
|
]}
|
||||||
rows={filteredNodes.map((node) => [
|
rows={filteredNodes.map((node) => [
|
||||||
<span
|
<div key={node.num}>
|
||||||
key={node.num}
|
<Avatar text={node.user?.shortName.toString() ?? "UNK"} />
|
||||||
className="h-3 w-3 rounded-full bg-accent"
|
</div>,
|
||||||
/>,
|
|
||||||
|
|
||||||
<h1
|
<h1
|
||||||
key="shortName"
|
key="shortName"
|
||||||
@@ -107,8 +108,8 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{node.user?.shortName ??
|
{node.user?.shortName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
? `${base16
|
? `${base16
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
.toLowerCase()}`
|
.toLowerCase()}`
|
||||||
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
||||||
</h1>,
|
</h1>,
|
||||||
|
|
||||||
@@ -120,8 +121,8 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{node.user?.longName ??
|
{node.user?.longName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
? `Meshtastic ${base16
|
? `Meshtastic ${base16
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
.toLowerCase()}`
|
.toLowerCase()}`
|
||||||
: `!${numberToHexUnpadded(node.num)}`)}
|
: `!${numberToHexUnpadded(node.num)}`)}
|
||||||
</h1>,
|
</h1>,
|
||||||
|
|
||||||
@@ -157,9 +158,8 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{node.lastHeard !== 0
|
{node.lastHeard !== 0
|
||||||
? node.viaMqtt === false && node.hopsAway === 0
|
? node.viaMqtt === false && node.hopsAway === 0
|
||||||
? "Direct"
|
? "Direct"
|
||||||
: `${node.hopsAway.toString()} ${
|
: `${node.hopsAway.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
|
||||||
node.hopsAway > 1 ? "hops" : "hop"
|
} away`
|
||||||
} away`
|
|
||||||
: "-"}
|
: "-"}
|
||||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
|
|||||||
Reference in New Issue
Block a user