From a00cb2098b5f23d624edb92df0aa5a026f573ae8 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Wed, 5 Nov 2025 21:53:55 -0500 Subject: [PATCH] feat(ui): match avatar color other platforms (#933) * feat(ui): match avatar color other platforms * Update packages/web/src/components/UI/Avatar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/DeviceInfoPanel.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/CommandPalette/index.tsx | 9 +- .../web/src/components/DeviceInfoPanel.tsx | 8 +- .../Map/Layers/PrecisionLayer.tsx | 8 +- .../PageComponents/Map/Markers/NodeMarker.tsx | 2 +- .../PageComponents/Map/Popups/NodeDetail.tsx | 2 +- .../PageComponents/Messages/MessageItem.tsx | 5 +- packages/web/src/components/Sidebar.tsx | 5 +- packages/web/src/components/UI/Avatar.tsx | 22 +++-- packages/web/src/core/utils/color.test.ts | 93 +++++++++++++++++++ packages/web/src/core/utils/color.ts | 28 ++---- packages/web/src/pages/Messages.tsx | 2 +- packages/web/src/pages/Nodes/index.tsx | 2 +- 12 files changed, 130 insertions(+), 56 deletions(-) create mode 100644 packages/web/src/core/utils/color.test.ts diff --git a/packages/web/src/components/CommandPalette/index.tsx b/packages/web/src/components/CommandPalette/index.tsx index ba53c980..afda6b5c 100644 --- a/packages/web/src/components/CommandPalette/index.tsx +++ b/packages/web/src/components/CommandPalette/index.tsx @@ -126,14 +126,7 @@ export const CommandPalette = () => { label: getNode(device.hardware.myNodeNum)?.user?.longName ?? t("unknown.shortName"), - icon: ( - - ), + icon: , action() { setSelectedDevice(device.id); }, diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx index c9a94020..66d90e77 100644 --- a/packages/web/src/components/DeviceInfoPanel.tsx +++ b/packages/web/src/components/DeviceInfoPanel.tsx @@ -1,5 +1,6 @@ import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts"; import { cn } from "@core/utils/cn.ts"; +import type { Protobuf } from "@meshtastic/core"; import { useNavigate } from "@tanstack/react-router"; import { ChevronRight, @@ -25,10 +26,7 @@ interface DeviceInfoPanelProps { isCollapsed: boolean; deviceMetrics: DeviceMetrics; firmwareVersion: string; - user: { - shortName: string; - longName: string; - }; + user: Protobuf.Mesh.User; setDialogOpen: () => void; setCommandPaletteOpen: () => void; disableHover?: boolean; @@ -144,7 +142,7 @@ export const DeviceInfoPanel = ({ )} > diff --git a/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx index dd0ebca1..22b8d024 100644 --- a/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx +++ b/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx @@ -1,7 +1,6 @@ -import { getColorFromText, isLightColor } from "@app/core/utils/color"; +import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color"; import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts"; import type { Protobuf } from "@meshtastic/core"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { circle } from "@turf/turf"; import type { Feature, FeatureCollection, Polygon } from "geojson"; import { Layer, Source } from "react-map-gl/maplibre"; @@ -46,10 +45,7 @@ export function generatePrecisionCircles( const [lng, lat] = toLngLat(node.position); const radiusM = precisionBitsToMeters(node.position?.precisionBits ?? 0); - const safeText = - node.user?.shortName ?? - numberToHexUnpadded(node.num).slice(-4).toUpperCase(); - const color = getColorFromText(safeText); + const color = getColorFromNodeNum(node.num); const isLight = isLightColor(color); const key = `${lat},${lng}:${radiusM}`; diff --git a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx index c5dade90..5592ca90 100644 --- a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx +++ b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx @@ -68,7 +68,7 @@ export const NodeMarker = memo(function NodeMarker({ onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })} > {
- +
{ diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx index 27fe2d97..16cd394c 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -144,7 +144,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { return message.from != null ? getNode(message.from) : null; }, [getNode, message.from]); - const { displayName, shortName, isFavorite } = useMemo(() => { + const { displayName, isFavorite, nodeNum } = useMemo(() => { const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0"); const last4 = userIdHex.slice(-4); const fallbackName = t("fallbackName", { last4 }); @@ -157,6 +157,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { displayName: derivedDisplayName, shortName: derivedShortName, isFavorite: isFavorite, + nodeNum: message.from, }; }, [messageUser, message.from, t, myNodeNum]); @@ -205,7 +206,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index a804d51c..432f24e4 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -204,10 +204,7 @@ export const Sidebar = ({ children }: SidebarProps) => { isCollapsed={isCollapsed} setCommandPaletteOpen={() => setCommandPaletteOpen(true)} setDialogOpen={() => setDialogOpen("deviceName", true)} - user={{ - longName: myNode?.user?.longName ?? t("unknown.longName"), - shortName: myNode?.user?.shortName ?? t("unknown.shortName"), - }} + user={myNode.user} firmwareVersion={ myMetadata?.firmwareVersion ?? t("unknown.notAvailable") } diff --git a/packages/web/src/components/UI/Avatar.tsx b/packages/web/src/components/UI/Avatar.tsx index 12a1ea48..9252bc76 100644 --- a/packages/web/src/components/UI/Avatar.tsx +++ b/packages/web/src/components/UI/Avatar.tsx @@ -1,4 +1,5 @@ -import { getColorFromText, isLightColor } from "@app/core/utils/color"; +import { useNodeDB } from "@app/core/stores"; +import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color"; import { Tooltip, TooltipArrow, @@ -11,7 +12,7 @@ import { LockKeyholeOpenIcon, StarIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; interface AvatarProps { - text: string | number; + nodeNum: number; size?: "sm" | "lg"; className?: string; showError?: boolean; @@ -19,24 +20,33 @@ interface AvatarProps { } export const Avatar = ({ - text, + nodeNum, size = "sm", showError = false, showFavorite = false, className, }: AvatarProps) => { const { t } = useTranslation(); + const { getNode } = useNodeDB(); + const node = getNode(nodeNum); + + if (!nodeNum) { + return null; + } const sizes = { sm: "size-10 text-xs font-light", lg: "size-16 text-lg", }; - const safeText = text?.toString().toUpperCase(); - const bgColor = getColorFromText(safeText); + const shortName = node?.user?.shortName ?? ""; + const longName = node?.user?.longName ?? ""; + const displayName = shortName || longName; + + const bgColor = getColorFromNodeNum(nodeNum); const isLight = isLightColor(bgColor); const textColor = isLight ? "#000000" : "#FFFFFF"; - const initials = safeText?.slice(0, 4) ?? t("unknown.shortName"); + const initials = displayName.slice(0, 4) || t("unknown.shortName"); return (
{ + it.each([ + [0x000000, { r: 0, g: 0, b: 0 }], + [0xffffff, { r: 255, g: 255, b: 255 }], + [0x123456, { r: 0x12, g: 0x34, b: 0x56 }], + [0xff8000, { r: 255, g: 128, b: 0 }], + ])("parses 0x%s correctly", (hex, expected) => { + expect(hexToRgb(hex)).toEqual(expected); + }); +}); + +describe("rgbToHex", () => { + it.each<[RGBColor, number]>([ + [{ r: 0, g: 0, b: 0 }, 0x000000], + [{ r: 255, g: 255, b: 255 }, 0xffffff], + [{ r: 0x12, g: 0x34, b: 0x56 }, 0x123456], + [{ r: 255, g: 128, b: 0 }, 0xff8000], + ])("packs %j into 0x%s", (rgb, expected) => { + expect(rgbToHex(rgb)).toBe(expected); + }); + + it("rounds component values before packing", () => { + expect(rgbToHex({ r: 12.2, g: 12.8, b: 99.5 })).toBe( + (12 << 16) | (13 << 8) | 100, + ); + }); +}); + +describe("hexToRgb ⟷ rgbToHex round-trip", () => { + it("is identity for representative values (masked to 24-bit)", () => { + const samples = [0, 1, 0x7fffff, 0x800000, 0xffffff, 0x123456, 0x00ff00]; + for (const hex of samples) { + const rgb = hexToRgb(hex); + expect(rgbToHex(rgb)).toBe(hex & 0xffffff); + } + }); + + it("holds for random 24-bit values", () => { + for (let i = 0; i < 100; i++) { + const hex = Math.floor(Math.random() * 0x1000000); // 0..0xFFFFFF + expect(rgbToHex(hexToRgb(hex))).toBe(hex); + } + }); +}); + +describe("isLightColor", () => { + it("detects obvious extremes", () => { + expect(isLightColor({ r: 255, g: 255, b: 255 })).toBe(true); // white + expect(isLightColor({ r: 0, g: 0, b: 0 })).toBe(false); // black + }); + + it("respects the 127.5 threshold at boundary", () => { + // mid-gray 127 → false, 128 → true (given the formula and 127.5 threshold) + expect(isLightColor({ r: 127, g: 127, b: 127 })).toBe(false); + expect(isLightColor({ r: 128, g: 128, b: 128 })).toBe(true); + }); +}); + +describe("getColorFromNodeNum", () => { + it.each([ + [0x000000, { r: 0, g: 0, b: 0 }], + [0xffffff, { r: 255, g: 255, b: 255 }], + [0x123456, { r: 0x12, g: 0x34, b: 0x56 }], + ])("extracts RGB from lower 24 bits of %s", (nodeNum, expected) => { + expect(getColorFromNodeNum(nodeNum)).toEqual(expected); + }); + + it("matches hexToRgb when masking to 24 bits", () => { + const nodeNums = [1127947528, 42, 999999, 0xfeef12, 0xfeedface, -123456]; + for (const n of nodeNums) { + // JS bitwise ops use signed 32-bit, so mask the lower 24 bits for comparison. + const masked = n & 0xffffff; + expect(getColorFromNodeNum(n)).toEqual(hexToRgb(masked)); + } + }); + + it("always yields components within 0..255", () => { + const color = getColorFromNodeNum(Math.floor(Math.random() * 2 ** 31)); + for (const v of Object.values(color)) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(255); + } + }); +}); diff --git a/packages/web/src/core/utils/color.ts b/packages/web/src/core/utils/color.ts index 9abb7b30..cfbe286d 100644 --- a/packages/web/src/core/utils/color.ts +++ b/packages/web/src/core/utils/color.ts @@ -2,39 +2,25 @@ export interface RGBColor { r: number; g: number; b: number; - a: number; } export const hexToRgb = (hex: number): RGBColor => ({ r: (hex & 0xff0000) >> 16, g: (hex & 0x00ff00) >> 8, b: hex & 0x0000ff, - a: 255, }); export const rgbToHex = (c: RGBColor): number => - (Math.round(c.a) << 24) | - (Math.round(c.r) << 16) | - (Math.round(c.g) << 8) | - Math.round(c.b); + (Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b); export const isLightColor = (c: RGBColor): boolean => (c.r * 299 + c.g * 587 + c.b * 114) / 1000 > 127.5; -export const getColorFromText = (text: string): RGBColor => { - if (!text) { - return { r: 0, g: 0, b: 0, a: 255 }; - } +export const getColorFromNodeNum = (nodeNum: number): RGBColor => { + // Extract RGB values directly from nodeNum (treated as hex color) + const r = (nodeNum & 0xff0000) >> 16; + const g = (nodeNum & 0x00ff00) >> 8; + const b = nodeNum & 0x0000ff; - let hash = 0; - for (let i = 0; i < text.length; i++) { - hash = text.charCodeAt(i) + ((hash << 5) - hash); - hash |= 0; // force 32‑bit - } - return { - r: (hash & 0xff0000) >> 16, - g: (hash & 0x00ff00) >> 8, - b: hash & 0x0000ff, - a: 255, - }; + return { r, g, b }; }; diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index e2ed979c..389495aa 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -283,7 +283,7 @@ export const MessagesPage = () => { }} > { { content: (