From 733a8b3229e77094f4ce83785035adcd48a422d4 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:10:16 -0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(primitives):=20PeerIdenticon=20?= =?UTF-8?q?=E2=80=94=20deterministic=20constellation=20mark=20from=20dest?= =?UTF-8?q?=20hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peers are pseudonymous; raw hash-prefix initials read as noise. Each peer now gets a deterministic geometric sigil: a core node orbited by 3-5 satellites joined by spokes/chords (a tiny mesh topology), with an accent hue from an 8-hue neon-on-navy palette. Node count, rotation, orbit bands, link pattern and hue all derive from FNV-1a/mulberry32 over the hash — same hash always renders the same mark, no randomness at render time. Pure spec function with a module-level cache + React.memo keeps 100+ row lists cheap (~9 SVG elements per mark). Optional online dot and container border overrides match how PeerAvatar call sites behave today. --- .../components/primitives/PeerIdenticon.tsx | 240 ++++++++++++++++++ mobile_app/components/primitives/index.ts | 3 + 2 files changed, 243 insertions(+) create mode 100644 mobile_app/components/primitives/PeerIdenticon.tsx diff --git a/mobile_app/components/primitives/PeerIdenticon.tsx b/mobile_app/components/primitives/PeerIdenticon.tsx new file mode 100644 index 00000000..61a8c1a2 --- /dev/null +++ b/mobile_app/components/primitives/PeerIdenticon.tsx @@ -0,0 +1,240 @@ +import React, { memo, useMemo } from "react"; +import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native"; +import Svg, { Circle, Path } from "react-native-svg"; + +import { useTheme } from "@/theme"; + +// ── Deterministic constellation mark ───────────────────────────────────────── +// +// Peers are pseudonymous — the dest hash IS the identity. Raw hash prefixes +// ("0c3", ",EMe") read as noise, so each peer gets a deterministic geometric +// sigil instead: a core node orbited by 3-5 satellite nodes joined by spokes +// and optional chords — a tiny mesh topology. Same hash → same mark, always. +// Everything (node count, rotation, orbit radii, link pattern, accent hue) is +// derived from the hash; nothing is random at render time. + +const VB = 44; // design viewBox — geometry scales to any rendered size +const C = VB / 2; + +// Accent hues tuned for the void-navy surfaces. Cyan/neon anchor the set to +// the brand palette (theme/colors.ts); the rest fill the wheel so ~8 nearby +// peers stay distinguishable by hue alone. +const ACCENTS = [ + "#00e5ff", // cyan — brand primary + "#4dffc3", // mint + "#5cff3b", // neon green — brand accent + "#ffd166", // amber + "#ff9e64", // coral + "#ff7ab8", // pink + "#b388ff", // violet + "#7aa2ff", // periwinkle +] as const; + +/** 32-bit FNV-1a (standard offset basis 0x811c9dc5 / prime 0x01000193). */ +function fnv1a(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} + +/** mulberry32 — tiny deterministic PRNG; seeded from the hash, so the "random" + * stream is a pure function of the peer identity. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +interface SigilNode { + x: number; + y: number; + r: number; +} + +interface SigilSpec { + accent: string; + core: SigilNode; + nodes: SigilNode[]; + /** Path d for core→satellite links. */ + spokes: string; + /** Path d for satellite→satellite links ('' when the hash draws none). */ + chords: string; +} + +function computeSpec(seed: string): SigilSpec { + const rand = mulberry32(fnv1a(seed)); + // Hue comes from an independent (salted) hash stream so near-identical + // layouts still split on color, and vice versa. Verified roughly uniform + // across both random-hex hashes and handle-style fallback seeds. + const accent = ACCENTS[fnv1a(`${seed}:hue`) % ACCENTS.length]; + + const count = 3 + Math.floor(rand() * 3); // 3..5 satellites + const rotation = rand() * Math.PI * 2; + + const coreAngle = rand() * Math.PI * 2; + const coreDist = rand() * 4.5; + const core: SigilNode = { + x: C + Math.cos(coreAngle) * coreDist, + y: C + Math.sin(coreAngle) * coreDist, + r: 3.2, + }; + + const nodes: SigilNode[] = []; + let spokes = ""; + for (let i = 0; i < count; i++) { + const jitter = (rand() - 0.5) * 0.5; + const angle = rotation + (i * Math.PI * 2) / count + jitter; + const orbit = rand() < 0.5 ? 12.5 : 16.5; // two discrete orbit bands + const r = rand() < 0.5 ? 2.3 : 3.0; + const x = C + Math.cos(angle) * orbit; + const y = C + Math.sin(angle) * orbit; + nodes.push({ x, y, r }); + spokes += `M${core.x.toFixed(2)} ${core.y.toFixed(2)}L${x.toFixed(2)} ${y.toFixed(2)}`; + } + + let chords = ""; + for (let i = 0; i < count; i++) { + if (rand() < 0.45) { + const a = nodes[i]; + const b = nodes[(i + 1) % count]; + chords += `M${a.x.toFixed(2)} ${a.y.toFixed(2)}L${b.x.toFixed(2)} ${b.y.toFixed(2)}`; + } + } + + return { accent, core, nodes, spokes, chords }; +} + +// Specs are tiny and shared across surfaces (drawer row, thread header, map +// node for the same peer) — cache module-wide. Soft cap guards pathological +// growth; normal peer counts never approach it. +const specCache = new Map(); + +function specFor(seed: string): SigilSpec { + let spec = specCache.get(seed); + if (!spec) { + if (specCache.size >= 512) specCache.clear(); + spec = computeSpec(seed); + specCache.set(seed, spec); + } + return spec; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export interface PeerIdenticonProps { + /** Stable identity string — the peer's LXMF destination hash. */ + seed: string; + /** Rendered diameter in px. Designed to read at 36-44 row size. */ + size?: number; + /** Presence affordance — renders the bottom-right status dot when defined, + * matching how PeerAvatar rows behave today. */ + online?: boolean; + /** Container overrides for embedded contexts (map nodes, avatar stacks). */ + backgroundColor?: string; + borderColor?: string; + borderWidth?: number; + style?: StyleProp; +} + +export const PeerIdenticon = memo(function PeerIdenticon({ + seed, + size = 44, + online, + backgroundColor, + borderColor, + borderWidth = 0.5, + style, +}: PeerIdenticonProps) { + const { colors } = useTheme(); + const spec = useMemo(() => specFor(seed), [seed]); + + // Below row size (map nodes, stacks) hairline strokes vanish — thicken the + // geometry so the sigil still reads as structure. + const bold = size < 32 ? 1.3 : 1; + const dotSize = Math.max(8, Math.round(size * 0.25)); + const inner = size - borderWidth * 2; + + return ( + + + {/* Faint hue field — gives each mark its own glow without extra elements */} + + {spec.chords !== "" && ( + + )} + + {spec.nodes.map((n, i) => ( + + ))} + + + {online !== undefined && ( + + )} + + ); +}); + +const S = StyleSheet.create({ + box: { + alignItems: "center", + justifyContent: "center", + }, + dot: { + borderWidth: 2, + bottom: 0, + position: "absolute", + right: 0, + }, +}); diff --git a/mobile_app/components/primitives/index.ts b/mobile_app/components/primitives/index.ts index 8c12cbcf..86dd9faf 100644 --- a/mobile_app/components/primitives/index.ts +++ b/mobile_app/components/primitives/index.ts @@ -17,6 +17,9 @@ export type { AppBottomSheetProps } from "./BottomSheet"; export { default as NumericKeypad } from "./NumericKeypad"; +export { PeerIdenticon } from "./PeerIdenticon"; +export type { PeerIdenticonProps } from "./PeerIdenticon"; + export { Pill } from "./Pill"; export type { PillTone } from "./Pill"; From 9ebd0dd1df516b62987bd47fbaf5eae94fea7c52 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:28:32 -0800 Subject: [PATCH 2/3] refactor(messages): swap initials avatars for PeerIdenticon in peer rows, member sheet, thread header, nearby card Seed = destHash (handle fallback) so the same peer shows the same mark on every surface. Beacons keep the radio glyph (infrastructure, not people); groups keep GroupAvatar. ThreadHeader takes destHash so the thread mark matches the drawer row even for Anonymous peers. --- .../components/home/NearbyPeersCard.tsx | 50 ++++--------------- .../components/messages/GroupMembersSheet.tsx | 11 ++-- .../components/messages/PeersDrawer.tsx | 30 +++++------ .../components/messages/ThreadHeader.tsx | 11 +++- mobile_app/screens/MessagesScreen.tsx | 1 + 5 files changed, 40 insertions(+), 63 deletions(-) diff --git a/mobile_app/components/home/NearbyPeersCard.tsx b/mobile_app/components/home/NearbyPeersCard.tsx index 8e441c2a..515e6d8f 100644 --- a/mobile_app/components/home/NearbyPeersCard.tsx +++ b/mobile_app/components/home/NearbyPeersCard.tsx @@ -2,7 +2,7 @@ import { useRouter } from "expo-router"; import React, { useMemo } from "react"; import { StyleSheet, Text, View } from "react-native"; -import { Icon, Pill, PressSurface } from "@/components/primitives"; +import { Icon, PeerIdenticon, Pill, PressSurface } from "@/components/primitives"; import type { PillTone } from "@/components/primitives"; import { useLxmfContext } from "@/context/LxmfContext"; import { useTheme } from "@/theme"; @@ -10,11 +10,6 @@ import { useTheme } from "@/theme"; const AVATAR_PREVIEW_COUNT = 3; const FRESH_WINDOW_SEC = 120; // peers announce-heard within 2 min count as "nearby" -function initialOf(alias: string | undefined): string { - if (!alias) return "?"; - return alias.trim().charAt(0).toUpperCase() || "?"; -} - // Peer presence strip above Recent. // // LxmfContext.peers is keyed by destHash and pruned to recent announces. @@ -79,19 +74,6 @@ export function NearbyPeersCard() { ? "green" : "amber"; - const avatarPaletteBg = [ - colors.primarySubtle, - colors.successSubtle, - colors.accentSubtle, - colors.warningSubtle, - ]; - const avatarPaletteFg = [ - colors.primary, - colors.success, - colors.accent, - colors.warning, - ]; - return ( 0 ? ( {previewPeers.map((peer, index) => ( - - - {initialOf(peer.displayName)} - - + seed={peer.destHash} + size={32} + style={{ marginLeft: index === 0 ? 0 : -10 }} + /> ))} {extraCount > 0 ? ( ['colors']; baseGlass: object; }) { - const name = getDisplayName(hash); - const initials = name.slice(0, 2).toUpperCase(); + const name = getDisplayName(hash); return ( - - {initials} - + {name} {hash} @@ -103,8 +100,6 @@ const S = StyleSheet.create({ countText: { fontFamily: fontFamily.sansMd, fontSize: 10, letterSpacing: 1.5, textTransform: 'uppercase' }, list: { flexGrow: 0 }, memberRow: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 10, borderRadius: radii.md }, - avatar: { width: 34, height: 34, borderRadius: radii.md, alignItems: 'center', justifyContent: 'center', borderWidth: 0.5 }, - avatarText: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, fontWeight: '600' }, memberInfo: { flex: 1, minWidth: 0 }, memberName: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, letterSpacing: 0.2 }, memberHash: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 0.5, marginTop: 2, opacity: 0.6 }, diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index 5085621d..daeb7be5 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -7,6 +7,7 @@ import Reanimated, { useSharedValue, useAnimatedStyle, withSpring, interpolate, Extrapolation, } from 'react-native-reanimated'; import { fontFamily, fontSize, radii, spacing, useTheme } from '@/theme'; +import { PeerIdenticon } from '@/components/primitives'; import { Pill } from '@/components/ui/Pill'; import { Skeleton } from '@/components/ui/Skeleton'; import { confirm } from '@/components/ui/ConfirmSheet'; @@ -140,20 +141,23 @@ function GroupAvatar() { ); } -function PeerAvatar({ p, online, borderColor, bg, textColor }: { - readonly p: Peer; readonly online: boolean; readonly borderColor: string; readonly bg: string; readonly textColor: string; +function PeerAvatar({ p, online, borderColor, bg }: { + readonly p: Peer; readonly online: boolean; readonly borderColor: string; readonly bg: string; }) { - return ( - - - {p.beacon - ? - : {p.handle.slice(5, 9)} - } + // Beacons are infrastructure, not pseudonymous people — keep the radio mark. + if (p.beacon) { + return ( + + + + + - - - ); + ); + } + // The dest hash is the identity — render its deterministic constellation + // mark so "same peer as before" is recognizable at a glance. + return ; } // ── Tab bar ─────────────────────────────────────────────────────────────────── @@ -266,7 +270,6 @@ function PeerRow({ online={p.online} bg={colors.surface2} borderColor={colors.border} - textColor={colors.textSecondary} /> @@ -514,7 +517,6 @@ const S = StyleSheet.create({ // ── Avatar ─────────────────────────────────────────────────────────────────── avatarWrap: { position: 'relative', width: 44, height: 44 }, avatar: { width: 44, height: 44, borderRadius: radii.full, alignItems: 'center', justifyContent: 'center', borderWidth: 0.5 }, - avatarText: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, fontWeight: '600' }, groupHash: { fontFamily: fontFamily.sansMd, fontSize: fontSize.lg, fontWeight: '700', color: '#4ecdc4' }, statusDot: { position: 'absolute', bottom: 0, right: 0, width: 11, height: 11, borderRadius: radii.full, borderWidth: 2, borderColor: '#060f16' }, diff --git a/mobile_app/components/messages/ThreadHeader.tsx b/mobile_app/components/messages/ThreadHeader.tsx index 4280b4a4..9a00b0c3 100644 --- a/mobile_app/components/messages/ThreadHeader.tsx +++ b/mobile_app/components/messages/ThreadHeader.tsx @@ -3,6 +3,7 @@ import { View, Text, Pressable, TextInput, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Feather } from '@expo/vector-icons'; import { fontFamily, fontSize, radii, useTheme } from '@/theme'; +import { PeerIdenticon } from '@/components/primitives'; import { Pill } from '@/components/ui/Pill'; import { useGlass } from '../../hooks/useGlass'; import { useLxmfContext } from '@/context/LxmfContext'; @@ -19,6 +20,9 @@ interface Props { nameKnown?: boolean; /** 8-char hash prefix for anonymous-peer display. */ hashShort?: string; + /** Full dest hash — seeds the identicon so the header mark matches the + * drawer row for the same peer. */ + destHash?: string; onOpen: () => void; onShareQR?: () => void; onShowMembers?: () => void; @@ -113,7 +117,7 @@ function EditIcon({ onPress }: { readonly onPress: () => void }) { // ── ThreadHeader ────────────────────────────────────────────────────────────── -export const ThreadHeader = memo(function ThreadHeader({ peer, selfName, hops, iface, online, isGroup, memberCount, nameKnown, hashShort, onOpen, onShareQR, onShowMembers }: Props) { +export const ThreadHeader = memo(function ThreadHeader({ peer, selfName, hops, iface, online, isGroup, memberCount, nameKnown, hashShort, destHash, onOpen, onShareQR, onShowMembers }: Props) { const { colors } = useTheme(); const baseGlass = useGlass(); const { updateDisplayName } = useLxmfContext(); @@ -191,6 +195,11 @@ export const ThreadHeader = memo(function ThreadHeader({ peer, selfName, hops, i + {/* Same constellation mark as the drawer row — confirms "same peer" even + when the thread shows Anonymous · prefix. Presence already has its own + dot in the status row, so the identicon renders without one. */} + {hasPeer && !isGroup && !!destHash && } + {hasPeer && isGroup && } {hasPeer && !isGroup && } diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 756053d8..9dcaa1fd 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -921,6 +921,7 @@ export default function MessagesScreen() { memberCount={activeGroupMembers.length} nameKnown={activePeerObj?.nameKnown ?? true} hashShort={activePeerHex ? activePeerHex.slice(0, 8) : undefined} + destHash={activePeerHex ?? undefined} onOpen={goBack} onShareQR={activePeerObj?.isGroup ? () => setShareSheetOpen(true) : undefined} onShowMembers={activePeerObj?.isGroup ? () => setMembersSheetOpen(true) : undefined} From e8562fdfc1a74e2929ea7dd185440315ae7db61b Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:28:39 -0800 Subject: [PATCH 3/3] refactor(nodes): mesh map nodes render PeerIdenticon instead of palette initials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same destHash seed as drawer/thread, so tapping a map node and opening the conversation shows one continuous identity. Online keeps its existing read (opacity + iface border) — no status dot at 24px. Drops AVATAR_PALETTE + nodeAvatar dead code. --- mobile_app/components/nodes/MeshMap.tsx | 43 ++++++------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/mobile_app/components/nodes/MeshMap.tsx b/mobile_app/components/nodes/MeshMap.tsx index 78556e9f..4b876830 100644 --- a/mobile_app/components/nodes/MeshMap.tsx +++ b/mobile_app/components/nodes/MeshMap.tsx @@ -11,6 +11,7 @@ import Reanimated, { useSharedValue, useAnimatedStyle, runOnJS, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PeerIdenticon } from '@/components/primitives'; import { fontFamily, fontSize, radii, useTheme } from '@/theme'; import type { NodeData } from './types'; @@ -117,27 +118,6 @@ function edges(placed: Placed[]): Edge[] { const IFACE_COLOR_KEY = { TCP: 'primary', BLE: 'accent', RNode: 'textSecondary' } as const; -// ── Avatar ──────────────────────────────────────────────────────────────────── -const AVATAR_PALETTE = [ - '#FF6B6B', '#FF9F43', '#FECA57', '#48DBFB', '#FF9FF3', - '#54A0FF', '#5F27CD', '#00D2D3', '#10AC84', '#EE5A24', - '#C8D6E5', '#01ABC6', -]; - -function hashCode(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; - return Math.abs(h); -} - -function nodeAvatar(handle: string | null | undefined): { initial: string; color: string } { - const clean = (handle ?? '').replace('@', ''); - return { - initial: clean.slice(0, 1).toUpperCase() || '?', - color: AVATAR_PALETTE[hashCode(clean) % AVATAR_PALETTE.length]!, - }; -} - // ── PeerNode — memo so only the 2 selection-changing nodes re-render on tap ── interface PeerNodeProps { node: NodeData; @@ -157,7 +137,6 @@ const IFACE_ICON: Record - - - {avatar.initial} - - + {/* Same destHash-seeded mark as the drawer/thread surfaces — the map node + for a peer matches their row. Online reads via opacity + iface border + (no status dot at 24px). Seed falls back to handle like nodeId(). */} + {/* Interface type badge — top-right corner */}