From fd9e327c85544fc8c30fe77fb99746591d494f5a Mon Sep 17 00:00:00 2001 From: sgtwilko Date: Fri, 30 Aug 2024 01:01:44 +0100 Subject: [PATCH 01/23] Use window.location.host so that websites run on non-standard ports include the port --- src/components/PageComponents/Connect/HTTP.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index 1eb7ca5c..ae049be2 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:", }, }); From d9ad044ecdecab4b9fb9523b3dedccd35587ae8f Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Wed, 29 Jan 2025 16:08:55 -0500 Subject: [PATCH 02/23] fix: improve styling of messsges --- .../PageComponents/Messages/ChannelChat.tsx | 96 ++++++++++--------- .../PageComponents/Messages/Message.tsx | 89 +++++++++-------- .../PageComponents/Messages/MessageInput.tsx | 2 +- src/components/UI/Footer.tsx | 2 +- src/components/UI/Sidebar/sidebarButton.tsx | 5 +- 5 files changed, 100 insertions(+), 94 deletions(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 79950bf8..0771df04 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -8,6 +8,7 @@ import { MessageInput } from "@components/PageComponents/Messages/MessageInput.t import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import type { Protobuf, Types } from "@meshtastic/js"; import { InboxIcon } from "lucide-react"; +import type { JSX } from "react"; export interface ChannelChatProps { messages?: MessageWithState[]; @@ -16,6 +17,13 @@ export interface ChannelChatProps { traceroutes?: Types.PacketMetadata[]; } +const EmptyState = () => ( +
+ + No Messages +
+); + export const ChannelChat = ({ messages, channel, @@ -24,53 +32,53 @@ export const ChannelChat = ({ }: ChannelChatProps): JSX.Element => { const { nodes } = useDevice(); + if (!messages?.length) { + return ( + <> +
+ +
+
+ +
+ + ); + } + return ( -
-
-
- {messages ? ( - messages.map((message, index) => ( - - )) - ) : ( -
- - No Messages -
- )} + <> +
+
+ {messages.map((message, index) => ( + 0 && messages[index - 1].from === message.from + } + sender={nodes.get(message.from)} + /> + ))}
-
- {to === "broadcast" ? null : traceroutes ? ( - traceroutes.map((traceroute, index) => ( - - )) - ) : ( -
- - No Traceroutes -
- )} +
+
-
- -
-
+ {/* {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..82312079 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,11 +1,7 @@ import type { MessageWithState } from "@app/core/stores/deviceStore.ts"; 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"; export interface MessageProps { lastMsgSameUser: boolean; @@ -13,61 +9,62 @@ export interface MessageProps { sender?: Protobuf.Mesh.NodeInfo; } +const StatusIcon = ({ state }: { state: MessageWithState["state"] }) => { + const iconClass = "text-gray-500 dark:text-gray-400 w-4 h-4"; + switch (state) { + case "ack": + return ; + case "waiting": + return ; + default: + return ; + } +}; + export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - return lastMsgSameUser ? ( -
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - - )} - - {message.data} - -
- ) : ( -
-
-
- + const messageTextClass = + message.state === "ack" + ? "text-gray-900 dark:text-white" + : "text-gray-500 dark:text-gray-400"; + if (lastMsgSameUser) { + return ( +
+
+
+ {message.data} +
+
- +
+ ); + } + + return ( +
+
+ + {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 ae88f9ac..079cb448 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -4,7 +4,7 @@ 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, useCallback, useMemo, useState } from "react"; export interface MessageInputProps { to: Types.Destination; 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 (
{Icon && } {element && element} - {label} + {label} ); From 3d3b59686cd6ae3d6261e8efdb18de7b4b5fcd99 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 31 Jan 2025 15:57:23 -0500 Subject: [PATCH 03/23] feat: add multi select component. feat: add multi select to position flags section --- src/components/Form/DynamicFormField.tsx | 9 +- src/components/Form/FormMultiSelect.tsx | 60 ++++++++ src/components/Form/FormSelect.tsx | 4 +- .../PageComponents/Config/Position.tsx | 29 +++- src/components/UI/MultiSelect.tsx | 57 +++++++ src/core/hooks/usePositionFlags.ts | 142 ++++++++++++++++++ 6 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 src/components/Form/FormMultiSelect.tsx create mode 100644 src/components/UI/MultiSelect.tsx create mode 100644 src/core/hooks/usePositionFlags.ts 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..3f0ed2fd --- /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..32d83cdd 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -1,25 +1,39 @@ +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"; -export const Position = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig } = useDevice(); +export const Position = () => { + const { config, setWorkingConfig } = useDevice(); + const { flagsValue, activeFlags, toggleFlag } = 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 = (name: string) => { + return toggleFlag(name as FlagName); + }; + return ( - onSubmit={onSubmit} + onSubmit={(data) => { + data.positionFlags = flagsValue; + return onSubmit(data); + }} defaultValues={config.position} fieldGroups={[ { @@ -53,7 +67,12 @@ export const Position = (): JSX.Element => { { type: "multiSelect", name: "positionFlags", + value: activeFlags, + isChecked: (name: string) => + activeFlags.includes(name as FlagName), + onValueChange: onPositonFlagChange, label: "Position Flags", + placeholder: "Select position flags...", description: "Configuration options for Position messages", properties: { enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags, diff --git a/src/components/UI/MultiSelect.tsx b/src/components/UI/MultiSelect.tsx new file mode 100644 index 00000000..79466ee0 --- /dev/null +++ b/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 ( +
{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/core/hooks/usePositionFlags.ts b/src/core/hooks/usePositionFlags.ts new file mode 100644 index 00000000..aacd28ac --- /dev/null +++ b/src/core/hooks/usePositionFlags.ts @@ -0,0 +1,142 @@ +import { useCallback, useMemo, useState } from "react"; + +export type FlagName = + | "UNSET" + | "ALTITUDE" + | "ALTITUDE_MSL" + | "GEOIDAL_SEPARATION" + | "DOP" + | "HVDOP" + | "SATINVIEW" + | "SEQ_NO" + | "TIMESTAMP" + | "HEADING" + | "SPEED"; + +type UsePositionFlagsProps = { + decode: (value: number) => FlagName[]; + encode: (flagNames: FlagName[]) => number; + hasFlag: (value: number, flagName: FlagName) => boolean; + getAllFlags: () => FlagName[]; + isValidValue: (value: number) => boolean; + flagsValue: number; + activeFlags: FlagName[]; + toggleFlag: (flagName: FlagName) => void; + setFlag: (flagName: FlagName, enabled: boolean) => void; + setFlags: (value: number) => void; + clearFlags: () => void; +}; + +const FLAGS_MAP: ReadonlyMap = new Map([ + ["UNSET", 0], + ["ALTITUDE", 1], + ["ALTITUDE_MSL", 2], + ["GEOIDAL_SEPARATION", 4], + ["DOP", 8], + ["HVDOP", 16], + ["SATINVIEW", 32], + ["SEQ_NO", 64], + ["TIMESTAMP", 128], + ["HEADING", 256], + ["SPEED", 512], +]); + +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 FLAGS_MAP) { + if (flagValue !== 0 && (value & flagValue) === flagValue) { + activeFlags.push(name); + } + } + return activeFlags; + }; + + const encode = (flagNames: FlagName[]): number => { + if (flagNames.includes("UNSET")) { + return 0; + } + return flagNames.reduce((acc, name) => { + const value = FLAGS_MAP.get(name); + if (value === undefined) { + throw new Error(`Invalid flag name: ${name}`); + } + return acc | value; + }, 0); + }; + + const hasFlag = (value: number, flagName: FlagName): boolean => { + const flagValue = FLAGS_MAP.get(flagName); + if (flagValue === undefined) { + throw new Error(`Invalid flag name: ${flagName}`); + } + return (value & flagValue) === flagValue; + }; + + const getAllFlags = (): FlagName[] => { + return Array.from(FLAGS_MAP.keys()); + }; + + const isValidValue = (value: number): boolean => { + const maxValue = Array.from(FLAGS_MAP.values()).reduce( + (a, b) => a + b, + 0, + ); + return Number.isInteger(value) && value >= 0 && value <= maxValue; + }; + + return { + decode, + encode, + hasFlag, + getAllFlags, + isValidValue, + }; + }, []); + + const toggleFlag = useCallback((flagName: FlagName) => { + const flagValue = FLAGS_MAP.get(flagName); + if (flagValue === undefined) { + throw new Error(`Invalid flag name: ${flagName}`); + } + setFlagsValue((prev) => prev ^ flagValue); + }, []); + + const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { + const flagValue = FLAGS_MAP.get(flagName); + if (flagValue === undefined) { + throw new Error(`Invalid flag name: ${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, + }; +}; From 75d681701220bbe7bef0eaebeee4cc555542ac2a Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 31 Jan 2025 16:17:59 -0500 Subject: [PATCH 04/23] chore: linting fix --- src/components/Form/FormMultiSelect.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Form/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx index 3f0ed2fd..1ca20575 100644 --- a/src/components/Form/FormMultiSelect.tsx +++ b/src/components/Form/FormMultiSelect.tsx @@ -28,8 +28,8 @@ export function MultiSelectInput({ // 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") + .filter((value) => typeof value[1] === "number") + .filter((value) => value[0] !== "UNSET") : []; const formatName = (name: string) => { From 794d214636d41be492f0ebbf1d73f96831e9946b Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 3 Feb 2025 12:36:33 -0500 Subject: [PATCH 05/23] feat: removed traceroute from message screen. --- .../PageComponents/Messages/ChannelChat.tsx | 20 +------------------ src/pages/Messages.tsx | 15 -------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 0771df04..4c155bfb 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -5,8 +5,7 @@ import { } 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 type { JSX } from "react"; @@ -14,7 +13,6 @@ export interface ChannelChatProps { messages?: MessageWithState[]; channel: Types.ChannelNumber; to: Types.Destination; - traceroutes?: Types.PacketMetadata[]; } const EmptyState = () => ( @@ -28,7 +26,6 @@ export const ChannelChat = ({ messages, channel, to, - traceroutes, }: ChannelChatProps): JSX.Element => { const { nodes } = useDevice(); @@ -64,21 +61,6 @@ export const ChannelChat = ({
- {/* {to === "broadcast" ? null : traceroutes ? ( - traceroutes.map((traceroute, index) => ( - - )) - ) : ( -
- - No Traceroutes -
- )} */} ); }; diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index ce866ed8..a7e70f18 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -108,21 +108,6 @@ 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.", - }), - ); - }, - }, ] : [] } From a4e21ed3433f805c31174d58c01f3d515749d23b Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 3 Feb 2025 13:17:57 -0500 Subject: [PATCH 06/23] feat: added hover tooltip on message status --- .../PageComponents/Messages/ChannelChat.tsx | 1 - .../PageComponents/Messages/Message.tsx | 55 +++++++++++++++++-- src/pages/Messages.tsx | 1 - 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 4c155bfb..fc7a60a1 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -1,4 +1,3 @@ -import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; import { type MessageWithState, useDevice, diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index 82312079..53244080 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,6 +1,7 @@ import type { MessageWithState } from "@app/core/stores/deviceStore.ts"; import { Avatar } from "@components/UI/Avatar"; import type { Protobuf } from "@meshtastic/js"; +import * as Tooltip from "@radix-ui/react-tooltip"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; export interface MessageProps { @@ -9,23 +10,67 @@ export interface MessageProps { sender?: Protobuf.Mesh.NodeInfo; } -const StatusIcon = ({ state }: { state: MessageWithState["state"] }) => { - const iconClass = "text-gray-500 dark:text-gray-400 w-4 h-4"; +interface StatusTooltipProps { + state: MessageWithState["state"]; + children: React.ReactNode; +} + +const getStatusText = (state: MessageWithState["state"]): string => { switch (state) { case "ack": - return ; + return "Message delivered"; case "waiting": - return ; + return "Waiting for delivery"; default: - return ; + return "Delivery failed"; } }; +const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( + + + {children} + + + {getStatusText(state)} + + + + + +); + +const StatusIcon = ({ state }: { state: MessageWithState["state"] }) => { + const iconClass = "text-gray-500 dark:text-gray-400 w-4 h-4"; + const Icon = (() => { + switch (state) { + case "ack": + return CheckCircle2; + case "waiting": + return CircleEllipsis; + default: + return AlertCircle; + } + })(); + + return ( + + + + ); +}; + export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { const messageTextClass = message.state === "ack" ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400"; + if (lastMsgSameUser) { return (
diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index a7e70f18..533c7c48 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -131,7 +131,6 @@ const MessagesPage = () => { to={activeChat} messages={messages.direct.get(node.num)} channel={Types.ChannelNumber.Primary} - traceroutes={traceroutes.get(node.num)} /> ), )} From 4736fa6b50843fef9390ff28eb6f3fa5a5fb65eb Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 3 Feb 2025 16:06:24 -0500 Subject: [PATCH 07/23] fix: increasing size of clickable area for navigation items --- src/components/UI/Sidebar/sidebarButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UI/Sidebar/sidebarButton.tsx b/src/components/UI/Sidebar/sidebarButton.tsx index 1db72b46..4f5146d4 100644 --- a/src/components/UI/Sidebar/sidebarButton.tsx +++ b/src/components/UI/Sidebar/sidebarButton.tsx @@ -21,7 +21,7 @@ export const SidebarButton = ({ onClick={onClick} variant={active ? "subtle" : "ghost"} size="sm" - className="flex gap-2" + className="flex gap-2 w-full" > {Icon && } {element && element} From 87ddaad966e613877ef52c3ebc1cffc4ce8014a5 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 3 Feb 2025 20:28:01 -0500 Subject: [PATCH 08/23] fix: styling updates, chat conversation moved to bottom of chat window. --- .../PageComponents/Messages/ChannelChat.tsx | 45 +++++++--- .../PageComponents/Messages/Message.tsx | 90 ++++++++++--------- 2 files changed, 83 insertions(+), 52 deletions(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index fc7a60a1..46423237 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -6,6 +6,7 @@ import { Message } from "@components/PageComponents/Messages/Message.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; 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 { @@ -27,24 +28,45 @@ export const ChannelChat = ({ to, }: 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.map((message, index) => ( ))} +
-
- -
- +
+ +
+
); }; diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index 53244080..3a96fd3b 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,4 +1,5 @@ import type { MessageWithState } 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 * as Tooltip from "@radix-ui/react-tooltip"; @@ -45,8 +46,14 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( ); -const StatusIcon = ({ state }: { state: MessageWithState["state"] }) => { - const iconClass = "text-gray-500 dark:text-gray-400 w-4 h-4"; +const StatusIcon = ({ + state, + className, +}: { state: MessageWithState["state"]; className?: string }) => { + const iconClass = cn( + className, + "text-gray-500 dark:text-gray-400 w-4 h-4 flex-shrink-0", + ); const Icon = (() => { switch (state) { case "ack": @@ -57,7 +64,6 @@ const StatusIcon = ({ state }: { state: MessageWithState["state"] }) => { return AlertCircle; } })(); - return ( @@ -66,50 +72,52 @@ const StatusIcon = ({ state }: { state: MessageWithState["state"] }) => { }; export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - const messageTextClass = + const messageTextClass = cn( + "border-l-2 pl-4 break-words min-w-0", message.state === "ack" ? "text-gray-900 dark:text-white" - : "text-gray-500 dark:text-gray-400"; + : "text-gray-500 dark:text-gray-400", + lastMsgSameUser + ? "border-gray-600 dark:border-gray-700" + : "border-gray-200 dark:border-gray-600", + ); - if (lastMsgSameUser) { - return ( -
-
-
- {message.data} -
- -
-
- ); - } + const baseMessageWrapper = cn( + "ml-12 flex items-start gap-2 w-full max-w-full", + lastMsgSameUser ? "mt-1" : "mt-4", + !lastMsgSameUser && "flex-wrap flex-grow", + ); + + const containerClass = cn( + "px-4 relative", + lastMsgSameUser ? "mt-0" : "mt-2", + !lastMsgSameUser && "pt-2", + ); return ( -
-
- - - {sender?.user?.longName ?? "UNK"} - - - {message.rxTime.toLocaleDateString()} - - - {message.rxTime.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} - -
-
-
- {message.data} +
+ {!lastMsgSameUser && ( +
+ + + {sender?.user?.longName ?? "UNK"} + + + {message.rxTime.toLocaleDateString()} + + + {message.rxTime.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + +
+ )} +
+
+
{message.data}
- +
); From 1c7b466e64cdec1b91decd374142dc23c35bfed1 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Tue, 4 Feb 2025 14:46:46 -0500 Subject: [PATCH 09/23] fix: styling fixes from code review --- .../PageComponents/Messages/ChannelChat.tsx | 15 +- .../PageComponents/Messages/Message.tsx | 143 +++++++++++------- src/components/PageLayout.tsx | 2 +- 3 files changed, 95 insertions(+), 65 deletions(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 46423237..47c9095c 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -39,7 +39,6 @@ export const ChannelChat = ({ scrollContainer.scrollTop - scrollContainer.clientHeight < 100; - if (isNearBottom) { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); } @@ -52,8 +51,8 @@ export const ChannelChat = ({ if (!messages?.length) { return ( -
-
+
+
@@ -64,9 +63,9 @@ export const ChannelChat = ({ } return ( -
-
-
+
+
+
{messages.map((message, index) => ( ))} -
+
-
+
diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index 3a96fd3b..66268219 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -4,28 +4,45 @@ import { Avatar } from "@components/UI/Avatar"; import type { Protobuf } from "@meshtastic/js"; import * as Tooltip from "@radix-ui/react-tooltip"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; -export interface MessageProps { +const MESSAGE_STATES = { + ACK: "ack", + WAITING: "waiting", + FAILED: "failed", +} as const; + +type MessageState = MessageWithState["state"]; + +interface MessageProps { lastMsgSameUser: boolean; message: MessageWithState; sender?: Protobuf.Mesh.NodeInfo; } interface StatusTooltipProps { - state: MessageWithState["state"]; + state: MessageState; children: React.ReactNode; } -const getStatusText = (state: MessageWithState["state"]): string => { - switch (state) { - case "ack": - return "Message delivered"; - case "waiting": - return "Waiting for delivery"; - default: - return "Delivery failed"; - } -}; +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) => ( @@ -46,78 +63,92 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( ); -const StatusIcon = ({ - state, - className, -}: { state: MessageWithState["state"]; className?: string }) => { +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 = (() => { - switch (state) { - case "ack": - return CheckCircle2; - case "waiting": - return CircleEllipsis; - default: - return AlertCircle; - } - })(); + + const Icon = STATUS_ICON_MAP[state]; return ( - + ); }; -export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - const messageTextClass = cn( - "border-l-2 pl-4 break-words min-w-0", - message.state === "ack" - ? "text-gray-900 dark:text-white" - : "text-gray-500 dark:text-gray-400", - lastMsgSameUser - ? "border-gray-600 dark:border-gray-700" - : "border-gray-200 dark:border-gray-600", +const getMessageTextStyles = (state: MessageState) => { + const isAcknowledged = state === MESSAGE_STATES.ACK; + const isFailed = state === MESSAGE_STATES.FAILED; + const isWaiting = state === MESSAGE_STATES.WAITING; + + return cn( + "pl-2 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 }: { date: Date }) => ( +
+ + {date.toLocaleDateString()} + + + {date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + +
+); + +export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { + const messageTextClass = getMessageTextStyles(message.state); + const isFailed = message.state === MESSAGE_STATES.ACK; const baseMessageWrapper = cn( - "ml-12 flex items-start gap-2 w-full max-w-full", - lastMsgSameUser ? "mt-1" : "mt-4", + "flex items-center gap-2 w-full max-w-full pl-11", !lastMsgSameUser && "flex-wrap flex-grow", ); const containerClass = cn( - "px-4 relative", - lastMsgSameUser ? "mt-0" : "mt-2", + "w-full px-4 relative", + lastMsgSameUser ? "mt-1" : "mt-2", !lastMsgSameUser && "pt-2", ); return (
{!lastMsgSameUser && ( -
- - - {sender?.user?.longName ?? "UNK"} - - - {message.rxTime.toLocaleDateString()} - - - {message.rxTime.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} - +
+
+ + + {sender?.user?.longName ?? "UNK"} + +
+
)}
-
+
{message.data}
- +
); diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 79495bdc..f25eecab 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -49,7 +49,7 @@ export const PageLayout = ({
From 921f9b21a29f4ca7ae6d395dfcde8d60a663ee76 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 7 Feb 2025 10:39:23 -0500 Subject: [PATCH 10/23] fix: added overflow scroll back to page layout --- src/components/PageLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 81ec4915..1009ab54 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -49,7 +49,7 @@ export const PageLayout = ({
From 76aea1a038476565a623a659dc4866bf766a0b5d Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 7 Feb 2025 10:45:40 -0500 Subject: [PATCH 11/23] fix: updated required package manager to use latest vrsion --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b685ffe..1a8a9261 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "simple-git-hooks": { "pre-commit": "npm run check:fix && npm run format" }, + "lint-staged": { + "*.{ts,tsx}": ["npm run check:fix", "npm run format"] + }, "repository": { "type": "git", "url": "git+https://github.com/meshtastic/web.git" @@ -87,5 +90,5 @@ "tar": "^7.4.3", "typescript": "^5.7.3" }, - "packageManager": "pnpm@9.15.4" + "packageManager": "pnpm@10.1.0" } From 53fe300fe9d11d4a54fe1a7e64847dbc285c717f Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 7 Feb 2025 11:08:59 -0500 Subject: [PATCH 12/23] fix: updating position flags to be more readable --- .../PageComponents/Config/Position.tsx | 17 ++-- src/components/UI/MultiSelect.tsx | 4 +- src/core/hooks/usePositionFlags.ts | 80 +++++++------------ 3 files changed, 42 insertions(+), 59 deletions(-) diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 32d83cdd..e81af178 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -6,10 +6,11 @@ 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 = () => { const { config, setWorkingConfig } = useDevice(); - const { flagsValue, activeFlags, toggleFlag } = usePositionFlags( + const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( config.position.positionFlags ?? 0, ); @@ -24,9 +25,12 @@ export const Position = () => { ); }; - const onPositonFlagChange = (name: string) => { - return toggleFlag(name as FlagName); - }; + const onPositonFlagChange = useCallback( + (name: string) => { + return toggleFlag(name as FlagName); + }, + [toggleFlag], + ); return ( @@ -73,9 +77,10 @@ export const Position = () => { onValueChange: onPositonFlagChange, label: "Position Flags", placeholder: "Select position flags...", - description: "Configuration options for Position messages", + 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/UI/MultiSelect.tsx b/src/components/UI/MultiSelect.tsx index 79466ee0..c5bf5914 100644 --- a/src/components/UI/MultiSelect.tsx +++ b/src/components/UI/MultiSelect.tsx @@ -46,10 +46,10 @@ const MultiSelectItem = ({ className, )} > - + - {children} + {children} ); }; diff --git a/src/core/hooks/usePositionFlags.ts b/src/core/hooks/usePositionFlags.ts index aacd28ac..95459372 100644 --- a/src/core/hooks/usePositionFlags.ts +++ b/src/core/hooks/usePositionFlags.ts @@ -1,23 +1,27 @@ import { useCallback, useMemo, useState } from "react"; -export type FlagName = - | "UNSET" - | "ALTITUDE" - | "ALTITUDE_MSL" - | "GEOIDAL_SEPARATION" - | "DOP" - | "HVDOP" - | "SATINVIEW" - | "SEQ_NO" - | "TIMESTAMP" - | "HEADING" - | "SPEED"; +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: () => FlagName[]; + getAllFlags: () => FlagsObject; isValidValue: (value: number) => boolean; flagsValue: number; activeFlags: FlagName[]; @@ -27,30 +31,17 @@ type UsePositionFlagsProps = { clearFlags: () => void; }; -const FLAGS_MAP: ReadonlyMap = new Map([ - ["UNSET", 0], - ["ALTITUDE", 1], - ["ALTITUDE_MSL", 2], - ["GEOIDAL_SEPARATION", 4], - ["DOP", 8], - ["HVDOP", 16], - ["SATINVIEW", 32], - ["SEQ_NO", 64], - ["TIMESTAMP", 128], - ["HEADING", 256], - ["SPEED", 512], -]); - 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 FLAGS_MAP) { + for (const [name, flagValue] of Object.entries(FLAGS)) { if (flagValue !== 0 && (value & flagValue) === flagValue) { - activeFlags.push(name); + activeFlags.push(name as FlagName); } } return activeFlags; @@ -61,31 +52,24 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { return 0; } return flagNames.reduce((acc, name) => { - const value = FLAGS_MAP.get(name); - if (value === undefined) { - throw new Error(`Invalid flag name: ${name}`); - } + const value = FLAGS[name]; return acc | value; }, 0); }; const hasFlag = (value: number, flagName: FlagName): boolean => { - const flagValue = FLAGS_MAP.get(flagName); - if (flagValue === undefined) { - throw new Error(`Invalid flag name: ${flagName}`); - } + const flagValue = FLAGS[flagName]; return (value & flagValue) === flagValue; }; - const getAllFlags = (): FlagName[] => { - return Array.from(FLAGS_MAP.keys()); + const getAllFlags = (): FlagsObject => { + return FLAGS; }; const isValidValue = (value: number): boolean => { - const maxValue = Array.from(FLAGS_MAP.values()).reduce( - (a, b) => a + b, - 0, - ); + 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; }; @@ -99,18 +83,12 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { }, []); const toggleFlag = useCallback((flagName: FlagName) => { - const flagValue = FLAGS_MAP.get(flagName); - if (flagValue === undefined) { - throw new Error(`Invalid flag name: ${flagName}`); - } + const flagValue = FLAGS[flagName]; setFlagsValue((prev) => prev ^ flagValue); }, []); const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { - const flagValue = FLAGS_MAP.get(flagName); - if (flagValue === undefined) { - throw new Error(`Invalid flag name: ${flagName}`); - } + const flagValue = FLAGS[flagName]; setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue)); }, []); From e3cc95cfd887f189e2d4d6f778b9669c7e8f75c4 Mon Sep 17 00:00:00 2001 From: Hunter275 Date: Fri, 7 Feb 2025 23:28:55 -0500 Subject: [PATCH 13/23] add missing maxBytes to MessageInput --- src/components/PageComponents/Messages/ChannelChat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 08d79832..8fb8365f 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -56,7 +56,7 @@ export const ChannelChat = ({
- +
); From 1fc72aa7be0b6cdaa4c89e159dc7a99ce73fed39 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 8 Feb 2025 08:32:29 -0500 Subject: [PATCH 14/23] fix: keep existing zoom on map ndoe click --- src/pages/Map.tsx | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/pages/Map.tsx b/src/pages/Map.tsx index 12a8e884..b2da0e69 100644 --- a/src/pages/Map.tsx +++ b/src/pages/Map.tsx @@ -44,7 +44,7 @@ const MapPage = (): JSX.Element => { } if (nodesWithPosition.length === 1) { map.easeTo({ - zoom: 12, + zoom: map.getZoom(), center: [ (nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7, (nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7, @@ -118,19 +118,6 @@ const MapPage = (): JSX.Element => { > { - // 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} @@ -163,11 +150,6 @@ const MapPage = (): JSX.Element => {
))} - {/* {rasterSources.map((source, index) => ( - - - - ))} */} {allNodes.map((node) => { if (node.position?.latitudeI && node.num !== selectedNode?.num) { return ( @@ -175,12 +157,11 @@ const MapPage = (): JSX.Element => { 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, + zoom: map.getZoom(), center: [ (node.position?.longitudeI ?? 0) / 1e7, (node.position?.latitudeI ?? 0) / 1e7, From d9aff939930952585444a416e765369b34c7ac26 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 8 Feb 2025 21:49:17 -0500 Subject: [PATCH 15/23] fix: removed map zoom on marker click. feat: lined up popup with map marker --- .../PageComponents/Map/NodeDetail.tsx | 2 +- src/pages/Map.tsx | 206 +++++++++--------- 2 files changed, 102 insertions(+), 106 deletions(-) diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 5203d077..6b33318d 100644 --- a/src/components/PageComponents/Map/NodeDetail.tsx +++ b/src/components/PageComponents/Map/NodeDetail.tsx @@ -30,7 +30,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { ].replaceAll("_", " "); return ( -
+
diff --git a/src/pages/Map.tsx b/src/pages/Map.tsx index b2da0e69..910de059 100644 --- a/src/pages/Map.tsx +++ b/src/pages/Map.tsx @@ -1,59 +1,94 @@ 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, + MarkerEvent, + 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: 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,65 +104,53 @@ 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) => ( - - ))} - - - + + { color: darkMode ? "black" : "", }} /> + + + + {waypoints.map((wp) => ( {
))} - {allNodes.map((node) => { - if (node.position?.latitudeI && node.num !== selectedNode?.num) { - return ( - { - setSelectedNode(node); - map?.easeTo({ - zoom: map.getZoom(), - center: [ - (node.position?.longitudeI ?? 0) / 1e7, - (node.position?.latitudeI ?? 0) / 1e7, - ], - }); - }} - > -
- - - {node.user?.longName || - `!${numberToHexUnpadded(node.num)}`} - -
-
- ); - } - })} - {selectedNode?.position && ( + {markers} + {selectedNode ? ( setSelectedNode(null)} > - )} + ) : null} From 2627b9035d4bc737c30a913b9654732514cfa9bc Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 8 Feb 2025 23:11:54 -0500 Subject: [PATCH 16/23] feat: adding pluralizer util. fix: measurement unit in marker popup --- .../PageComponents/Map/NodeDetail.tsx | 8 ++++- src/core/utils/string.ts | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/core/utils/string.ts diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 5203d077..23112f34 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/Table/tmp/TimeAgo.tsx"; @@ -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/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}`; +} From 467effa62e1cc21c5000440bc57d6775735caac5 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 10 Feb 2025 12:24:57 -0500 Subject: [PATCH 17/23] fix: prevent empty/blank messages from being sent. fix: count chars during copy/paste action --- .../PageComponents/Messages/MessageInput.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index 2e7954c9..d1cbc5ee 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 { type JSX, useCallback, useMemo, useState } from "react"; +import { + type JSX, + startTransition, + useCallback, + useMemo, + useState, +} from "react"; export interface MessageInputProps { to: Types.Destination; @@ -63,11 +69,11 @@ export const MessageInput = ({ const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - const byteLength = new Blob([newValue]).size; - if (byteLength <= maxBytes) { + const messageLength = newValue.length; + if (messageLength <= maxBytes) { setLocalDraft(newValue); debouncedSetMessageDraft(newValue); - setMessageBytes(maxBytes - byteLength); + setMessageBytes(maxBytes - messageLength); } }; @@ -75,11 +81,15 @@ 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(""); + }); }} >
@@ -87,6 +97,7 @@ export const MessageInput = ({ Date: Tue, 11 Feb 2025 15:03:20 -0500 Subject: [PATCH 18/23] fix: added dark modifer to message input (#414) --- src/components/PageComponents/Messages/ChannelChat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 8fb8365f..56ee39e9 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -55,7 +55,7 @@ export const ChannelChat = ({
-
+
From c6f70a7b772b762fc42202fb62b996d30bd2b161 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 13 Feb 2025 15:36:44 -0500 Subject: [PATCH 19/23] feat: added text style chat messages --- .../PageComponents/Messages/ChannelChat.tsx | 32 ++--- .../PageComponents/Messages/Message.tsx | 122 ++++++++++-------- .../PageComponents/Messages/MessageInput.tsx | 29 +++-- src/components/UI/Tooltip.tsx | 9 +- 4 files changed, 109 insertions(+), 83 deletions(-) diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 56ee39e9..cdbca329 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -51,7 +51,7 @@ export const ChannelChat = ({ if (!messages?.length) { return ( -
+
@@ -63,23 +63,25 @@ export const ChannelChat = ({ } return ( -
-
-
- {messages.map((message, index) => ( - 0 && messages[index - 1].from === message.from - } - sender={nodes.get(message.from)} - /> - ))} +
+
+
+ {messages.map((message, index) => { + return ( + 0 && messages[index - 1].from === message.from + } + /> + ); + })}
-
+
diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index 66268219..a6eb8f9a 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,10 +1,21 @@ -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 * as Tooltip from "@radix-ui/react-tooltip"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import { useMemo } from "react"; const MESSAGE_STATES = { ACK: "ack", @@ -17,7 +28,7 @@ type MessageState = MessageWithState["state"]; interface MessageProps { lastMsgSameUser: boolean; message: MessageWithState; - sender?: Protobuf.Mesh.NodeInfo; + sender: Protobuf.Mesh.NodeInfo; } interface StatusTooltipProps { @@ -45,22 +56,20 @@ const STATUS_ICON_MAP: Record = { const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( - - - {children} - - - {getStatusText(state)} - - - - - + + + {children} + + {getStatusText(state)} + + + + ); const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { @@ -88,7 +97,7 @@ const getMessageTextStyles = (state: MessageState) => { const isWaiting = state === MESSAGE_STATES.WAITING; return cn( - "pl-2 break-words overflow-hidden", + "break-words overflow-hidden", isAcknowledged ? "text-black dark:text-white" : "text-black dark:text-gray-400", @@ -96,8 +105,11 @@ const getMessageTextStyles = (state: MessageState) => { ); }; -const TimeDisplay = ({ date }: { date: Date }) => ( -
+const TimeDisplay = ({ + date, + className, +}: { date: Date; className?: string }) => ( +
{date.toLocaleDateString()} @@ -111,44 +123,46 @@ const TimeDisplay = ({ date }: { date: Date }) => ( ); export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - const messageTextClass = getMessageTextStyles(message.state); - const isFailed = message.state === MESSAGE_STATES.ACK; - - const baseMessageWrapper = cn( - "flex items-center gap-2 w-full max-w-full pl-11", - !lastMsgSameUser && "flex-wrap flex-grow", + 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 containerClass = cn( - "w-full px-4 relative", - lastMsgSameUser ? "mt-1" : "mt-2", - !lastMsgSameUser && "pt-2", - ); + const messageTextClass = getMessageTextStyles(message.state); return ( -
- {!lastMsgSameUser && ( -
-
- - - {sender?.user?.longName ?? "UNK"} - -
- +
+
+
+ {!lastMsgSameUser ? ( +
+ +
+ + {messageUser?.longName} + +
+
+ ) : null}
- )} -
-
-
{message.data}
+ +
+
+ {message.data} +
+
-
); diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index 2e7954c9..059d25f6 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -26,7 +26,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 +64,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); } }; @@ -80,21 +81,23 @@ export const MessageInput = ({ sendText(localDraft); setLocalDraft(""); setMessageDraft(""); + setMessageBytes(0); }} >
- - - -
- {messageBytes}/{maxBytes} + +
+ + {messageBytes}/{maxBytes} +
+ 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, +}; From 6d26996d653b6a9f89e5d57491152d0803064a51 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 14 Feb 2025 11:26:12 -0500 Subject: [PATCH 20/23] fix: fixed issue with toast background opacity in light mode --- src/components/UI/Toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", }, From 1c59d0451a552a0e0f6e0f17c309a8e9371aa7c6 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 14 Feb 2025 11:30:28 -0500 Subject: [PATCH 21/23] fix: lighted border of outline button in dark mode --- src/components/UI/Button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 5cc24fd6abbda8b814fe687c04420f865d97d35c Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 14 Feb 2025 11:41:06 -0500 Subject: [PATCH 22/23] fix: added border around markers --- src/pages/Map.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Map.tsx b/src/pages/Map.tsx index 910de059..606f5be6 100644 --- a/src/pages/Map.tsx +++ b/src/pages/Map.tsx @@ -12,7 +12,6 @@ import { AttributionControl, GeolocateControl, Marker, - MarkerEvent, NavigationControl, Popup, ScaleControl, @@ -121,6 +120,7 @@ const MapPage = (): JSX.Element => { > ); @@ -145,7 +145,7 @@ const MapPage = (): JSX.Element => { maxPitch={0} antialias={true} style={{ - filter: darkMode ? "brightness(0.8)" : "", + filter: darkMode ? "brightness(0.9)" : "", }} dragRotate={false} touchZoomRotate={false} From 22887b4dd62d035a85a69186748ee2d6a9069a52 Mon Sep 17 00:00:00 2001 From: Leo Lutz Date: Mon, 17 Feb 2025 23:36:21 -0700 Subject: [PATCH 23/23] Add avator to nodes table --- src/pages/Nodes.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index 93d12b7e..dd4a9765 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -1,6 +1,7 @@ import Footer from "@app/components/UI/Footer"; import { useAppStore } from "@app/core/stores/appStore"; 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"; @@ -56,10 +57,9 @@ const NodesPage = (): JSX.Element => { { title: "Remove", type: "normal", sortable: false }, ]} rows={filteredNodes.map((node) => [ - , +
+ +
,

{node.user?.longName ??