diff --git a/package.json b/package.json index 439a6f58..a1441b0d 100644 --- a/package.json +++ b/package.json @@ -89,5 +89,5 @@ "tar": "^7.4.3", "typescript": "^5.7.3" }, - "packageManager": "pnpm@9.15.4" + "packageManager": "pnpm@10.1.0" } diff --git a/src/components/Form/DynamicFormField.tsx b/src/components/Form/DynamicFormField.tsx index 388836d4..545ff0a7 100644 --- a/src/components/Form/DynamicFormField.tsx +++ b/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 = | InputFieldProps | SelectFieldProps + | MultiSelectFieldProps | ToggleFieldProps | PasswordGeneratorProps; @@ -58,6 +63,8 @@ export function DynamicFormField({ /> ); case "multiSelect": - return
tmp
; + return ( + + ); } } diff --git a/src/components/Form/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx new file mode 100644 index 00000000..1ca20575 --- /dev/null +++ b/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 extends BaseFormBuilderProps { + type: "multiSelect"; + placeholder?: string; + onValueChange: (name: string) => void; + isChecked: (name: string) => boolean; + value: string[]; + properties: BaseFormBuilderProps["properties"] & { + enumValue: { + [s: string]: string | number; + }; + formatEnumName?: boolean; + }; +} + +export function MultiSelectInput({ + field, +}: GenericFormElementProps>) { + 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 ( + + {optionsEnumValues.map(([name, value]) => ( + field.onValueChange(name)} + > + {formatEnumName ? formatName(name) : name} + + ))} + + ); +} diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index 95405127..bd0e24f0 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -12,7 +12,7 @@ import { import { Controller, type FieldValues } from "react-hook-form"; export interface SelectFieldProps extends BaseFormBuilderProps { - type: "select" | "multiSelect"; + type: "select"; properties: BaseFormBuilderProps["properties"] & { enumValue: { [s: string]: string | number; @@ -51,7 +51,7 @@ export function SelectInput({ {optionsEnumValues.map(([name, value]) => ( - + {formatEnumName ? name .replace(/_/g, " ") diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 798da2e9..e81af178 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/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 ( - 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(), }, }, { diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index de776327..0e1e6603 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/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:", }, }); diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 0aba4d4b..8f37120b 100644 --- a/src/components/PageComponents/Map/NodeDetail.tsx +++ b/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 ( -
+
@@ -132,7 +133,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { className="ml-2 mr-1" aria-label="Elevation" /> -
{node.position?.altitude} ft
+
+ {formatQuantity(node.position?.altitude, { + one: "meter", + other: "meters", + })} +
)}
diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index b636fbc6..cdbca329 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/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[]; } +const EmptyState = () => ( +
+ + No Messages +
+); + export const ChannelChat = ({ messages, channel, to, - traceroutes, }: ChannelChatProps): JSX.Element => { const { nodes } = useDevice(); + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(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 ( +
+
+ +
+
+ +
+
+ ); + } return ( -
-
-
- {messages ? ( - messages.map((message, index) => ( +
+
+
+ {messages.map((message, index) => { + return ( 0 && messages[index - 1].from === message.from } - sender={nodes.get(message.from)} - /> - )) - ) : ( -
- - No Messages -
- )} -
-
- {to === "broadcast" ? null : traceroutes ? ( - traceroutes.map((traceroute, index) => ( - - )) - ) : ( -
- - No Traceroutes -
- )} + ); + })} +
-
+
diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index e8030e2a..a6eb8f9a 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/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 = { + [MESSAGE_STATES.ACK]: "Message delivered", + [MESSAGE_STATES.WAITING]: "Waiting for delivery", + [MESSAGE_STATES.FAILED]: "Delivery failed", +} as const; + +const STATUS_ICON_MAP: Record = { + [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) => ( + + + {children} + + {getStatusText(state)} + + + + +); + +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 ( + + + + ); +}; + +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 }) => ( +
+ + {date.toLocaleDateString()} + + + {date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + +
+); + export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - return lastMsgSameUser ? ( -
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - - )} - + 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 ( +
+
- {message.data} - -
- ) : ( -
-
-
- +
+ {!lastMsgSameUser ? ( +
+ +
+ + {messageUser?.longName} + +
+
+ ) : null} +
+ +
+
+ {message.data} +
+
- - {sender?.user?.longName ?? "UNK"} - - - {message.rxTime.toLocaleDateString()} - - - {message.rxTime.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} - -
-
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - - )} - - {message.data} -
); diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index b9080bb2..e8b85357 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/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) => { 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 = ({
{ - 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); + }); }} >
@@ -87,14 +99,16 @@ export const MessageInput = ({ -
+
{messageBytes}/{maxBytes}
+ diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index 18861065..1c332689 100644 --- a/src/components/UI/Button.tsx +++ b/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: diff --git a/src/components/UI/Footer.tsx b/src/components/UI/Footer.tsx index d0e082e7..c50bb3e4 100644 --- a/src/components/UI/Footer.tsx +++ b/src/components/UI/Footer.tsx @@ -6,7 +6,7 @@ const Footer = React.forwardRef( ({ className, ...props }, ref) => { return (
{ + return ( +
{children}
+ ); +}; + +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 ( + 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, + )} + > + + + + {children} + + ); +}; + +export { MultiSelect, MultiSelectItem }; diff --git a/src/components/UI/Sidebar/sidebarButton.tsx b/src/components/UI/Sidebar/sidebarButton.tsx index c3f30e79..4f5146d4 100644 --- a/src/components/UI/Sidebar/sidebarButton.tsx +++ b/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 && } {element && element} - {label} + {label} ); diff --git a/src/components/UI/Toast.tsx b/src/components/UI/Toast.tsx index b2fd7131..c0eba845 100644 --- a/src/components/UI/Toast.tsx +++ b/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", }, diff --git a/src/components/UI/Tooltip.tsx b/src/components/UI/Tooltip.tsx index bde4b344..2353418a 100644 --- a/src/components/UI/Tooltip.tsx +++ b/src/components/UI/Tooltip.tsx @@ -9,6 +9,7 @@ const Tooltip = ({ ...props }) => ; Tooltip.displayName = TooltipPrimitive.Tooltip.displayName; const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipArrow = TooltipPrimitive.Arrow; const TooltipContent = React.forwardRef< React.ElementRef, @@ -26,4 +27,10 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + TooltipArrow, +}; diff --git a/src/core/hooks/usePositionFlags.ts b/src/core/hooks/usePositionFlags.ts new file mode 100644 index 00000000..95459372 --- /dev/null +++ b/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(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, + }; +}; diff --git a/src/core/utils/string.ts b/src/core/utils/string.ts new file mode 100644 index 00000000..2cabf70d --- /dev/null +++ b/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}`; +} diff --git a/src/pages/Map.tsx b/src/pages/Map.tsx index 12a8e884..606f5be6 100644 --- a/src/pages/Map.tsx +++ b/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(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 ( + handleMarkerClick(node, e)} + > + + + ); + }), + [validNodes, handleMarkerClick], + ); useEffect(() => { map?.on("load", () => { - getBBox(); + getMapBounds(); }); - }, [map, getBBox]); + }, [map, getMapBounds]); return ( <> - - - {rasterSources.map((source) => ( - - ))} - - - + + { - // 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" : "", }} /> + + + + {waypoints.map((wp) => ( {
))} - {/* {rasterSources.map((source, index) => ( - - - - ))} */} - {allNodes.map((node) => { - if (node.position?.latitudeI && node.num !== selectedNode?.num) { - return ( - { - setSelectedNode(node); - map?.easeTo({ - zoom: 12, - center: [ - (node.position?.longitudeI ?? 0) / 1e7, - (node.position?.latitudeI ?? 0) / 1e7, - ], - }); - }} - > -
- - - {node.user?.longName || - `!${numberToHexUnpadded(node.num)}`} - -
-
- ); - } - })} - {selectedNode?.position && ( + {markers} + {selectedNode ? ( setSelectedNode(null)} > - )} + ) : null} diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 7fe99f69..2ca47ceb 100644 --- a/src/pages/Messages.tsx +++ b/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)} /> ), )} diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index fa1c5788..7c002136 100644 --- a/src/pages/Nodes.tsx +++ b/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) => [ - , +
+ +
,

{ {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)}`)}

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