Browse Source

Merge branch 'master' into feature/node-quick-options

pull/307/head
Tilen Komel 1 year ago
parent
commit
971541a516
  1. 2
      package.json
  2. 9
      src/components/Form/DynamicFormField.tsx
  3. 60
      src/components/Form/FormMultiSelect.tsx
  4. 4
      src/components/Form/FormSelect.tsx
  5. 38
      src/components/PageComponents/Config/Position.tsx
  6. 2
      src/components/PageComponents/Connect/HTTP.tsx
  7. 10
      src/components/PageComponents/Map/NodeDetail.tsx
  8. 95
      src/components/PageComponents/Messages/ChannelChat.tsx
  9. 215
      src/components/PageComponents/Messages/Message.tsx
  10. 32
      src/components/PageComponents/Messages/MessageInput.tsx
  11. 2
      src/components/UI/Button.tsx
  12. 2
      src/components/UI/Footer.tsx
  13. 57
      src/components/UI/MultiSelect.tsx
  14. 5
      src/components/UI/Sidebar/sidebarButton.tsx
  15. 2
      src/components/UI/Toast.tsx
  16. 9
      src/components/UI/Tooltip.tsx
  17. 120
      src/core/hooks/usePositionFlags.ts
  18. 31
      src/core/utils/string.ts
  19. 229
      src/pages/Map.tsx
  20. 16
      src/pages/Messages.tsx
  21. 22
      src/pages/Nodes.tsx

2
package.json

@ -89,5 +89,5 @@
"tar": "^7.4.3",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4"
"packageManager": "pnpm@10.1.0"
}

9
src/components/Form/DynamicFormField.tsx

@ -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} />
);
}
}

60
src/components/Form/FormMultiSelect.tsx

@ -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>
);
}

4
src/components/Form/FormSelect.tsx

@ -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, " ")

38
src/components/PageComponents/Config/Position.tsx

@ -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(),
},
},
{

2
src/components/PageComponents/Connect/HTTP.tsx

@ -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:",
},
});

10
src/components/PageComponents/Map/NodeDetail.tsx

@ -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>

95
src/components/PageComponents/Messages/ChannelChat.tsx

@ -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}
sender={nodes.get(message.from)}
lastMsgSameUser={
index === 0
? false
: messages[index - 1].from === message.from
index > 0 && messages[index - 1].from === message.from
}
sender={nodes.get(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>

215
src/components/PageComponents/Messages/Message.tsx

@ -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";
const MESSAGE_STATES = {
ACK: "ack",
WAITING: "waiting",
FAILED: "failed",
} as const;
type MessageState = MessageWithState["state"];
export interface MessageProps {
interface MessageProps {
lastMsgSameUser: boolean;
message: MessageWithState;
sender?: Protobuf.Mesh.NodeInfo;
sender: Protobuf.Mesh.NodeInfo;
}
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}
>
{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) => {
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"
}`}
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",
)}
>
{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 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>
<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" />
)}
<span
className={`ml-4 border-l-2 border-l-backgroundPrimary pl-2 ${
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
}`}
>
{message.data}
</span>
</div>
</div>
);

32
src/components/PageComponents/Messages/MessageInput.tsx

@ -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>

2
src/components/UI/Button.tsx

@ -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:

2
src/components/UI/Footer.tsx

@ -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)",

57
src/components/UI/MultiSelect.tsx

@ -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 };

5
src/components/UI/Sidebar/sidebarButton.tsx

@ -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>
);

2
src/components/UI/Toast.tsx

@ -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
src/components/UI/Tooltip.tsx

@ -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,
};

120
src/core/hooks/usePositionFlags.ts

@ -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,
};
};

31
src/core/utils/string.ts

@ -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}`;
}

229
src/pages/Map.tsx

@ -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 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],
);
const getBBox = useCallback(() => {
// 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>
</>

16
src/pages/Messages.tsx

@ -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)}
/>
),
)}

22
src/pages/Nodes.tsx

@ -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>,

Loading…
Cancel
Save