Merge branch 'master' into feature/node-quick-options
This commit is contained in:
+1
-1
@@ -89,5 +89,5 @@
|
||||
"tar": "^7.4.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 {
|
||||
GenericInput,
|
||||
type InputFieldProps,
|
||||
@@ -19,6 +23,7 @@ import type { Control, FieldValues } from "react-hook-form";
|
||||
export type FieldProps<T> =
|
||||
| InputFieldProps<T>
|
||||
| SelectFieldProps<T>
|
||||
| MultiSelectFieldProps<T>
|
||||
| ToggleFieldProps<T>
|
||||
| PasswordGeneratorProps<T>;
|
||||
|
||||
@@ -58,6 +63,8 @@ export function DynamicFormField<T extends FieldValues>({
|
||||
/>
|
||||
);
|
||||
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";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select" | "multiSelect";
|
||||
type: "select";
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{optionsEnumValues.map(([name, value]) => (
|
||||
<SelectItem key={name + value} value={value.toString()}>
|
||||
<SelectItem key={name} value={value.toString()}>
|
||||
{formatEnumName
|
||||
? name
|
||||
.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 { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const Position = (): JSX.Element => {
|
||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
||||
export const Position = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
config.position.positionFlags ?? 0,
|
||||
);
|
||||
|
||||
const onSubmit = (data: PositionValidation) => {
|
||||
setWorkingConfig(
|
||||
return setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
payloadVariant: {
|
||||
case: "position",
|
||||
value: data,
|
||||
value: { ...data, positionFlags: flagsValue },
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onPositonFlagChange = useCallback(
|
||||
(name: string) => {
|
||||
return toggleFlag(name as FlagName);
|
||||
},
|
||||
[toggleFlag],
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicForm<PositionValidation>
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={(data) => {
|
||||
data.positionFlags = flagsValue;
|
||||
return onSubmit(data);
|
||||
}}
|
||||
defaultValues={config.position}
|
||||
fieldGroups={[
|
||||
{
|
||||
@@ -53,10 +71,16 @@ export const Position = (): JSX.Element => {
|
||||
{
|
||||
type: "multiSelect",
|
||||
name: "positionFlags",
|
||||
value: activeFlags,
|
||||
isChecked: (name: string) =>
|
||||
activeFlags.includes(name as FlagName),
|
||||
onValueChange: onPositonFlagChange,
|
||||
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: {
|
||||
enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags,
|
||||
enumValue: getAllFlags(),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
window.location.hostname,
|
||||
)
|
||||
? "meshtastic.local"
|
||||
: window.location.hostname,
|
||||
: window.location.host,
|
||||
tls: location.protocol === "https:",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Separator } from "@app/components/UI/Seperator";
|
||||
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { formatQuantity } from "@app/core/utils/string";
|
||||
import { Avatar } from "@components/UI/Avatar";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
@@ -30,7 +31,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
].replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<div className="dark:text-black">
|
||||
<div className="dark:text-black p-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={node.user?.shortName} />
|
||||
@@ -132,7 +133,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
className="ml-2 mr-1"
|
||||
aria-label="Elevation"
|
||||
/>
|
||||
<div>{node.position?.altitude} ft</div>
|
||||
<div>
|
||||
{formatQuantity(node.position?.altitude, {
|
||||
one: "meter",
|
||||
other: "meters",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,74 +1,87 @@
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import {
|
||||
type MessageWithState,
|
||||
useDevice,
|
||||
} from "@app/core/stores/deviceStore.ts";
|
||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||
import type { Protobuf, Types } from "@meshtastic/js";
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: MessageWithState[];
|
||||
channel: Types.ChannelNumber;
|
||||
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 = ({
|
||||
messages,
|
||||
channel,
|
||||
to,
|
||||
traceroutes,
|
||||
}: ChannelChatProps): JSX.Element => {
|
||||
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 (
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="flex flex-grow">
|
||||
<div className="flex flex-grow flex-col">
|
||||
{messages ? (
|
||||
messages.map((message, index) => (
|
||||
<div className="flex flex-col h-full w-full container mx-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
|
||||
{messages.map((message, index) => {
|
||||
return (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
lastMsgSameUser={
|
||||
index === 0
|
||||
? false
|
||||
: messages[index - 1].from === message.from
|
||||
}
|
||||
sender={nodes.get(message.from)}
|
||||
lastMsgSameUser={
|
||||
index > 0 && messages[index - 1].from === message.from
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="m-auto">
|
||||
<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 ref={messagesEndRef} className="w-full" />
|
||||
</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} />
|
||||
</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 type { Protobuf } from "@meshtastic/js";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleEllipsisIcon,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export interface MessageProps {
|
||||
const MESSAGE_STATES = {
|
||||
ACK: "ack",
|
||||
WAITING: "waiting",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
type MessageState = MessageWithState["state"];
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
message: MessageWithState;
|
||||
sender?: Protobuf.Mesh.NodeInfo;
|
||||
sender: Protobuf.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
return lastMsgSameUser ? (
|
||||
<div className="ml-5 flex">
|
||||
{message.state === "ack" ? (
|
||||
<CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
|
||||
) : message.state === "waiting" ? (
|
||||
<CircleEllipsisIcon size={16} className="my-auto text-textSecondary" />
|
||||
) : (
|
||||
<AlertCircleIcon size={16} className="my-auto text-textSecondary" />
|
||||
)}
|
||||
<span
|
||||
className={`ml-4 border-l-2 border-l-backgroundPrimary pl-2 ${
|
||||
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
|
||||
}`}
|
||||
interface StatusTooltipProps {
|
||||
state: MessageState;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
state: MessageState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_TEXT_MAP: Record<MessageState, string> = {
|
||||
[MESSAGE_STATES.ACK]: "Message delivered",
|
||||
[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}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mt-2 gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 cursor-pointer">
|
||||
<Avatar text={sender?.user?.shortName ?? "UNK"} />
|
||||
</div>
|
||||
<span className="cursor-pointer font-medium text-textPrimary">
|
||||
{sender?.user?.longName ?? "UNK"}
|
||||
</span>
|
||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||
{message.rxTime.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||
{message.rxTime.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-1 flex">
|
||||
{message.state === "ack" ? (
|
||||
<CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
|
||||
) : message.state === "waiting" ? (
|
||||
<CircleEllipsisIcon
|
||||
size={16}
|
||||
className="my-auto text-textSecondary"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircleIcon size={16} className="my-auto text-textSecondary" />
|
||||
{getStatusText(state)}
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const iconClass = cn(
|
||||
className,
|
||||
"text-gray-500 dark:text-gray-400 w-4 h-4 flex-shrink-0",
|
||||
);
|
||||
|
||||
const Icon = STATUS_ICON_MAP[state];
|
||||
return (
|
||||
<StatusTooltip state={state}>
|
||||
<Icon
|
||||
className={iconClass}
|
||||
{...otherProps}
|
||||
color={isFailed ? "red" : "currentColor"}
|
||||
/>
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
const isAcknowledged = state === MESSAGE_STATES.ACK;
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const isWaiting = state === MESSAGE_STATES.WAITING;
|
||||
|
||||
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 ${
|
||||
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
|
||||
}`}
|
||||
>
|
||||
{message.data}
|
||||
</span>
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser ? (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName} />
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,13 @@ import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
type JSX,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface MessageInputProps {
|
||||
to: Types.Destination;
|
||||
@@ -26,7 +32,7 @@ export const MessageInput = ({
|
||||
} = useDevice();
|
||||
const myNodeNum = hardware.myNodeNum;
|
||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||
const [messageBytes, setMessageBytes] = useState(maxBytes);
|
||||
const [messageBytes, setMessageBytes] = useState(0);
|
||||
|
||||
const debouncedSetMessageDraft = useMemo(
|
||||
() => debounce(setMessageDraft, 300),
|
||||
@@ -64,10 +70,11 @@ export const MessageInput = ({
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const byteLength = new Blob([newValue]).size;
|
||||
|
||||
if (byteLength <= maxBytes) {
|
||||
setLocalDraft(newValue);
|
||||
debouncedSetMessageDraft(newValue);
|
||||
setMessageBytes(maxBytes - byteLength);
|
||||
setMessageBytes(byteLength);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,11 +82,16 @@ export const MessageInput = ({
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendText(localDraft);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
action={async (formData: FormData) => {
|
||||
// prevent user from sending blank/empty message
|
||||
if (localDraft === "") return;
|
||||
const message = formData.get("messageInput") as string;
|
||||
startTransition(() => {
|
||||
sendText(message);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
setMessageBytes(0);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-grow gap-2">
|
||||
@@ -87,14 +99,16 @@ export const MessageInput = ({
|
||||
<Input
|
||||
autoFocus={true}
|
||||
minLength={1}
|
||||
name="messageInput"
|
||||
placeholder="Enter Message"
|
||||
value={localDraft}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center w-24 p-2 place-content-end">
|
||||
{messageBytes}/{maxBytes}
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -15,7 +15,7 @@ const buttonVariants = cva(
|
||||
success:
|
||||
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
|
||||
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:
|
||||
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
|
||||
ghost:
|
||||
|
||||
@@ -6,7 +6,7 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<footer
|
||||
className={`flex flex- justify-center p-2 ${className}`}
|
||||
className={`flex mt-auto justify-center p-2 ${className}`}
|
||||
style={{
|
||||
backgroundColor: "var(--backgroundPrimary)",
|
||||
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 type { LucideIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface SidebarButtonProps {
|
||||
label: string;
|
||||
@@ -20,10 +21,10 @@ export const SidebarButton = ({
|
||||
onClick={onClick}
|
||||
variant={active ? "subtle" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
className="flex gap-2 w-full"
|
||||
>
|
||||
{Icon && <Icon size={16} />}
|
||||
{element && element}
|
||||
{label}
|
||||
<span className="flex flex-1 justify-start flex-shrink-0">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ const toastVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
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:
|
||||
"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;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
const TooltipArrow = TooltipPrimitive.Arrow;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
@@ -26,4 +27,10 @@ const TooltipContent = React.forwardRef<
|
||||
));
|
||||
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 { 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 { 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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
import {
|
||||
BoxSelectIcon,
|
||||
MapPinIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import { MapPinIcon } from "lucide-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";
|
||||
|
||||
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 { nodes, waypoints } = useDevice();
|
||||
const { rasterSources, darkMode } = useAppStore();
|
||||
const { darkMode } = useAppStore();
|
||||
const { default: map } = useMap();
|
||||
|
||||
const [zoom, setZoom] = useState(0);
|
||||
const [selectedNode, setSelectedNode] =
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const nodesWithPosition = allNodes.filter(
|
||||
(node) => node.position?.latitudeI,
|
||||
);
|
||||
if (!nodesWithPosition.length) {
|
||||
|
||||
if (!validNodes.length) {
|
||||
return;
|
||||
}
|
||||
if (nodesWithPosition.length === 1) {
|
||||
if (validNodes.length === 1) {
|
||||
map.easeTo({
|
||||
zoom: 12,
|
||||
zoom: map.getZoom(),
|
||||
center: [
|
||||
(nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7,
|
||||
(nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7,
|
||||
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
|
||||
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const line = lineString(
|
||||
nodesWithPosition.map((n) => [
|
||||
validNodes.map((n) => [
|
||||
(n.position?.latitudeI ?? 0) / 1e7,
|
||||
(n.position?.longitudeI ?? 0) / 1e7,
|
||||
]),
|
||||
@@ -69,78 +103,54 @@ const MapPage = (): JSX.Element => {
|
||||
if (center) {
|
||||
map.easeTo(center);
|
||||
}
|
||||
}, [allNodes, map]);
|
||||
}, [validNodes, map]);
|
||||
|
||||
useEffect(() => {
|
||||
map?.on("zoom", () => {
|
||||
setZoom(map?.getZoom() ?? 0);
|
||||
});
|
||||
}, [map]);
|
||||
// Generate all markers
|
||||
const markers = useMemo(
|
||||
() =>
|
||||
validNodes.map((node) => {
|
||||
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(() => {
|
||||
map?.on("load", () => {
|
||||
getBBox();
|
||||
getMapBounds();
|
||||
});
|
||||
}, [map, getBBox]);
|
||||
}, [map, getMapBounds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar>
|
||||
<SidebarSection label="Sources">
|
||||
{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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Sidebar />
|
||||
<PageLayout label="Map" noPadding={true} actions={[]}>
|
||||
<MapGl
|
||||
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}
|
||||
renderWorldCopies={false}
|
||||
maxPitch={0}
|
||||
antialias={true}
|
||||
style={{
|
||||
filter: darkMode ? "brightness(0.8)" : "",
|
||||
filter: darkMode ? "brightness(0.9)" : "",
|
||||
}}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={false}
|
||||
initialViewState={{
|
||||
zoom: 1.6,
|
||||
zoom: 1.8,
|
||||
latitude: 35,
|
||||
longitude: 0,
|
||||
}}
|
||||
@@ -151,6 +161,14 @@ const MapPage = (): JSX.Element => {
|
||||
color: darkMode ? "black" : "",
|
||||
}}
|
||||
/>
|
||||
<GeolocateControl
|
||||
position="top-right"
|
||||
positionOptions={{ enableHighAccuracy: true }}
|
||||
trackUserLocation
|
||||
/>
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
|
||||
<ScaleControl />
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
@@ -163,58 +181,17 @@ const MapPage = (): JSX.Element => {
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* {rasterSources.map((source, index) => (
|
||||
<Source key={index} type="raster" {...source}>
|
||||
<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 && (
|
||||
{markers}
|
||||
{selectedNode ? (
|
||||
<Popup
|
||||
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7}
|
||||
anchor="left"
|
||||
closeOnClick={false}
|
||||
anchor="top"
|
||||
longitude={convertToLatLng(selectedNode.position).longitude}
|
||||
latitude={convertToLatLng(selectedNode.position).latitude}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
)}
|
||||
) : null}
|
||||
</MapGl>
|
||||
</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}
|
||||
messages={messages.direct.get(node.num)}
|
||||
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 Footer from "@app/components/UI/Footer";
|
||||
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 { Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
@@ -94,10 +96,9 @@ const NodesPage = (): JSX.Element => {
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<span
|
||||
key={node.num}
|
||||
className="h-3 w-3 rounded-full bg-accent"
|
||||
/>,
|
||||
<div key={node.num}>
|
||||
<Avatar text={node.user?.shortName.toString() ?? "UNK"} />
|
||||
</div>,
|
||||
|
||||
<h1
|
||||
key="shortName"
|
||||
@@ -107,8 +108,8 @@ const NodesPage = (): JSX.Element => {
|
||||
{node.user?.shortName ??
|
||||
(node.user?.macaddr
|
||||
? `${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
||||
</h1>,
|
||||
|
||||
@@ -120,8 +121,8 @@ const NodesPage = (): JSX.Element => {
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `!${numberToHexUnpadded(node.num)}`)}
|
||||
</h1>,
|
||||
|
||||
@@ -157,9 +158,8 @@ const NodesPage = (): JSX.Element => {
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: `${node.hopsAway.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
|
||||
Reference in New Issue
Block a user