Skip to content

Commit a00cb20

Browse files
danditomasoCopilot
andauthored
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 <[email protected]> * Update packages/web/src/components/DeviceInfoPanel.tsx Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 6aeaed9 commit a00cb20

File tree

12 files changed

+130
-56
lines changed

12 files changed

+130
-56
lines changed

packages/web/src/components/CommandPalette/index.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,7 @@ export const CommandPalette = () => {
126126
label:
127127
getNode(device.hardware.myNodeNum)?.user?.longName ??
128128
t("unknown.shortName"),
129-
icon: (
130-
<Avatar
131-
text={
132-
getNode(device.hardware.myNodeNum)?.user?.shortName ??
133-
t("unknown.shortName")
134-
}
135-
/>
136-
),
129+
icon: <Avatar nodeNum={device.hardware.myNodeNum} />,
137130
action() {
138131
setSelectedDevice(device.id);
139132
},

packages/web/src/components/DeviceInfoPanel.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
22
import { cn } from "@core/utils/cn.ts";
3+
import type { Protobuf } from "@meshtastic/core";
34
import { useNavigate } from "@tanstack/react-router";
45
import {
56
ChevronRight,
@@ -25,10 +26,7 @@ interface DeviceInfoPanelProps {
2526
isCollapsed: boolean;
2627
deviceMetrics: DeviceMetrics;
2728
firmwareVersion: string;
28-
user: {
29-
shortName: string;
30-
longName: string;
31-
};
29+
user: Protobuf.Mesh.User;
3230
setDialogOpen: () => void;
3331
setCommandPaletteOpen: () => void;
3432
disableHover?: boolean;
@@ -144,7 +142,7 @@ export const DeviceInfoPanel = ({
144142
)}
145143
>
146144
<Avatar
147-
text={user.shortName}
145+
nodeNum={parseInt(user.id.slice(1), 16)}
148146
className={cn("flex-shrink-0", isCollapsed && "")}
149147
size="sm"
150148
/>

packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { getColorFromText, isLightColor } from "@app/core/utils/color";
1+
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
22
import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts";
33
import type { Protobuf } from "@meshtastic/core";
4-
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
54
import { circle } from "@turf/turf";
65
import type { Feature, FeatureCollection, Polygon } from "geojson";
76
import { Layer, Source } from "react-map-gl/maplibre";
@@ -46,10 +45,7 @@ export function generatePrecisionCircles(
4645
const [lng, lat] = toLngLat(node.position);
4746
const radiusM = precisionBitsToMeters(node.position?.precisionBits ?? 0);
4847

49-
const safeText =
50-
node.user?.shortName ??
51-
numberToHexUnpadded(node.num).slice(-4).toUpperCase();
52-
const color = getColorFromText(safeText);
48+
const color = getColorFromNodeNum(node.num);
5349
const isLight = isLightColor(color);
5450

5551
const key = `${lat},${lng}:${radiusM}`;

packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const NodeMarker = memo(function NodeMarker({
6868
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
6969
>
7070
<Avatar
71-
text={label}
71+
nodeNum={id}
7272
className={cn(
7373
"border-[1.5px] border-slate-600 shadow-m shadow-slate-600",
7474
avatarClassName,

packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
5252
<div className="p-1 text-slate-900">
5353
<div className="flex gap-2">
5454
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
55-
<Avatar text={shortName} size="sm" />
55+
<Avatar nodeNum={node.num} size="sm" />
5656

5757
<div
5858
onFocusCapture={(e) => {

packages/web/src/components/PageComponents/Messages/MessageItem.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
144144
return message.from != null ? getNode(message.from) : null;
145145
}, [getNode, message.from]);
146146

147-
const { displayName, shortName, isFavorite } = useMemo(() => {
147+
const { displayName, isFavorite, nodeNum } = useMemo(() => {
148148
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
149149
const last4 = userIdHex.slice(-4);
150150
const fallbackName = t("fallbackName", { last4 });
@@ -157,6 +157,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
157157
displayName: derivedDisplayName,
158158
shortName: derivedShortName,
159159
isFavorite: isFavorite,
160+
nodeNum: message.from,
160161
};
161162
}, [messageUser, message.from, t, myNodeNum]);
162163

@@ -205,7 +206,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
205206
<div className="grid grid-cols-[auto_1fr] gap-x-2">
206207
<Avatar
207208
size="sm"
208-
text={shortName}
209+
nodeNum={nodeNum}
209210
className="pt-0.5"
210211
showFavorite={isFavorite}
211212
/>

packages/web/src/components/Sidebar.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
204204
isCollapsed={isCollapsed}
205205
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
206206
setDialogOpen={() => setDialogOpen("deviceName", true)}
207-
user={{
208-
longName: myNode?.user?.longName ?? t("unknown.longName"),
209-
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
210-
}}
207+
user={myNode.user}
211208
firmwareVersion={
212209
myMetadata?.firmwareVersion ?? t("unknown.notAvailable")
213210
}

packages/web/src/components/UI/Avatar.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { getColorFromText, isLightColor } from "@app/core/utils/color";
1+
import { useNodeDB } from "@app/core/stores";
2+
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
23
import {
34
Tooltip,
45
TooltipArrow,
@@ -11,32 +12,41 @@ import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
1112
import { useTranslation } from "react-i18next";
1213

1314
interface AvatarProps {
14-
text: string | number;
15+
nodeNum: number;
1516
size?: "sm" | "lg";
1617
className?: string;
1718
showError?: boolean;
1819
showFavorite?: boolean;
1920
}
2021

2122
export const Avatar = ({
22-
text,
23+
nodeNum,
2324
size = "sm",
2425
showError = false,
2526
showFavorite = false,
2627
className,
2728
}: AvatarProps) => {
2829
const { t } = useTranslation();
30+
const { getNode } = useNodeDB();
31+
const node = getNode(nodeNum);
32+
33+
if (!nodeNum) {
34+
return null;
35+
}
2936

3037
const sizes = {
3138
sm: "size-10 text-xs font-light",
3239
lg: "size-16 text-lg",
3340
};
3441

35-
const safeText = text?.toString().toUpperCase();
36-
const bgColor = getColorFromText(safeText);
42+
const shortName = node?.user?.shortName ?? "";
43+
const longName = node?.user?.longName ?? "";
44+
const displayName = shortName || longName;
45+
46+
const bgColor = getColorFromNodeNum(nodeNum);
3747
const isLight = isLightColor(bgColor);
3848
const textColor = isLight ? "#000000" : "#FFFFFF";
39-
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
49+
const initials = displayName.slice(0, 4) || t("unknown.shortName");
4050

4151
return (
4252
<div
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
getColorFromNodeNum,
4+
hexToRgb,
5+
isLightColor,
6+
type RGBColor,
7+
rgbToHex,
8+
} from "./color.ts";
9+
10+
describe("hexToRgb", () => {
11+
it.each([
12+
[0x000000, { r: 0, g: 0, b: 0 }],
13+
[0xffffff, { r: 255, g: 255, b: 255 }],
14+
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
15+
[0xff8000, { r: 255, g: 128, b: 0 }],
16+
])("parses 0x%s correctly", (hex, expected) => {
17+
expect(hexToRgb(hex)).toEqual(expected);
18+
});
19+
});
20+
21+
describe("rgbToHex", () => {
22+
it.each<[RGBColor, number]>([
23+
[{ r: 0, g: 0, b: 0 }, 0x000000],
24+
[{ r: 255, g: 255, b: 255 }, 0xffffff],
25+
[{ r: 0x12, g: 0x34, b: 0x56 }, 0x123456],
26+
[{ r: 255, g: 128, b: 0 }, 0xff8000],
27+
])("packs %j into 0x%s", (rgb, expected) => {
28+
expect(rgbToHex(rgb)).toBe(expected);
29+
});
30+
31+
it("rounds component values before packing", () => {
32+
expect(rgbToHex({ r: 12.2, g: 12.8, b: 99.5 })).toBe(
33+
(12 << 16) | (13 << 8) | 100,
34+
);
35+
});
36+
});
37+
38+
describe("hexToRgb ⟷ rgbToHex round-trip", () => {
39+
it("is identity for representative values (masked to 24-bit)", () => {
40+
const samples = [0, 1, 0x7fffff, 0x800000, 0xffffff, 0x123456, 0x00ff00];
41+
for (const hex of samples) {
42+
const rgb = hexToRgb(hex);
43+
expect(rgbToHex(rgb)).toBe(hex & 0xffffff);
44+
}
45+
});
46+
47+
it("holds for random 24-bit values", () => {
48+
for (let i = 0; i < 100; i++) {
49+
const hex = Math.floor(Math.random() * 0x1000000); // 0..0xFFFFFF
50+
expect(rgbToHex(hexToRgb(hex))).toBe(hex);
51+
}
52+
});
53+
});
54+
55+
describe("isLightColor", () => {
56+
it("detects obvious extremes", () => {
57+
expect(isLightColor({ r: 255, g: 255, b: 255 })).toBe(true); // white
58+
expect(isLightColor({ r: 0, g: 0, b: 0 })).toBe(false); // black
59+
});
60+
61+
it("respects the 127.5 threshold at boundary", () => {
62+
// mid-gray 127 → false, 128 → true (given the formula and 127.5 threshold)
63+
expect(isLightColor({ r: 127, g: 127, b: 127 })).toBe(false);
64+
expect(isLightColor({ r: 128, g: 128, b: 128 })).toBe(true);
65+
});
66+
});
67+
68+
describe("getColorFromNodeNum", () => {
69+
it.each([
70+
[0x000000, { r: 0, g: 0, b: 0 }],
71+
[0xffffff, { r: 255, g: 255, b: 255 }],
72+
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
73+
])("extracts RGB from lower 24 bits of %s", (nodeNum, expected) => {
74+
expect(getColorFromNodeNum(nodeNum)).toEqual(expected);
75+
});
76+
77+
it("matches hexToRgb when masking to 24 bits", () => {
78+
const nodeNums = [1127947528, 42, 999999, 0xfeef12, 0xfeedface, -123456];
79+
for (const n of nodeNums) {
80+
// JS bitwise ops use signed 32-bit, so mask the lower 24 bits for comparison.
81+
const masked = n & 0xffffff;
82+
expect(getColorFromNodeNum(n)).toEqual(hexToRgb(masked));
83+
}
84+
});
85+
86+
it("always yields components within 0..255", () => {
87+
const color = getColorFromNodeNum(Math.floor(Math.random() * 2 ** 31));
88+
for (const v of Object.values(color)) {
89+
expect(v).toBeGreaterThanOrEqual(0);
90+
expect(v).toBeLessThanOrEqual(255);
91+
}
92+
});
93+
});

packages/web/src/core/utils/color.ts

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,25 @@ export interface RGBColor {
22
r: number;
33
g: number;
44
b: number;
5-
a: number;
65
}
76

87
export const hexToRgb = (hex: number): RGBColor => ({
98
r: (hex & 0xff0000) >> 16,
109
g: (hex & 0x00ff00) >> 8,
1110
b: hex & 0x0000ff,
12-
a: 255,
1311
});
1412

1513
export const rgbToHex = (c: RGBColor): number =>
16-
(Math.round(c.a) << 24) |
17-
(Math.round(c.r) << 16) |
18-
(Math.round(c.g) << 8) |
19-
Math.round(c.b);
14+
(Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
2015

2116
export const isLightColor = (c: RGBColor): boolean =>
2217
(c.r * 299 + c.g * 587 + c.b * 114) / 1000 > 127.5;
2318

24-
export const getColorFromText = (text: string): RGBColor => {
25-
if (!text) {
26-
return { r: 0, g: 0, b: 0, a: 255 };
27-
}
19+
export const getColorFromNodeNum = (nodeNum: number): RGBColor => {
20+
// Extract RGB values directly from nodeNum (treated as hex color)
21+
const r = (nodeNum & 0xff0000) >> 16;
22+
const g = (nodeNum & 0x00ff00) >> 8;
23+
const b = nodeNum & 0x0000ff;
2824

29-
let hash = 0;
30-
for (let i = 0; i < text.length; i++) {
31-
hash = text.charCodeAt(i) + ((hash << 5) - hash);
32-
hash |= 0; // force 32‑bit
33-
}
34-
return {
35-
r: (hash & 0xff0000) >> 16,
36-
g: (hash & 0x00ff00) >> 8,
37-
b: hash & 0x0000ff,
38-
a: 255,
39-
};
25+
return { r, g, b };
4026
};

0 commit comments

Comments
 (0)