diff --git a/mobile_app/components/home/NearbyPeersCard.tsx b/mobile_app/components/home/NearbyPeersCard.tsx index 8e441c2..515e6d8 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 5085621..daeb7be 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 4280b4a..9a00b0c 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/components/nodes/MeshMap.tsx b/mobile_app/components/nodes/MeshMap.tsx index 78556e9..4b87683 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 */} >> 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 8c12cbc..86dd9fa 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"; diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 756053d..9dcaa1f 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}