diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bba9d33..3289260 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { toast } from './components/ui/sonner'; import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; +import { PathHopWidthProvider } from './contexts/PathHopWidthContext'; import { RichPayloadProvider } from './contexts/RichPayloadContext'; import { usePush } from './contexts/PushSubscriptionContext'; import { messageContainsMention } from './utils/messageParser'; @@ -119,12 +120,14 @@ export function App() { localLabel, distanceUnit, renderRichPayloads, + showPathHopWidth, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, setDistanceUnit, setRenderRichPayloads, + setShowPathHopWidth, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage: openNewMessageModal, @@ -807,34 +810,39 @@ export function App() { renderRichPayloads={renderRichPayloads} setRenderRichPayloads={setRenderRichPayloads} > - + + + ); diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 52cde48..4b2c63d 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -23,7 +23,8 @@ import { splitReplyMention, } from '../utils/meshcoreOpenPayloads'; import { useRichPayloads } from '../contexts/RichPayloadContext'; -import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; +import { usePathHopWidth } from '../contexts/PathHopWidthContext'; +import { formatHopCounts, formatPathHopWidths, type SenderInfo } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils'; import { ContactAvatar } from './ContactAvatar'; import { PathModal } from './PathModal'; @@ -302,8 +303,10 @@ interface HopCountBadgeProps { } function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) { + const { showPathHopWidth } = usePathHopWidth(); const hopInfo = formatHopCounts(paths); - const label = `(${hopInfo.display})`; + const widthLabel = showPathHopWidth ? formatPathHopWidths(paths) : null; + const label = widthLabel ? `(${hopInfo.display} · ${widthLabel})` : `(${hopInfo.display})`; const className = variant === 'header' @@ -320,8 +323,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) { e.stopPropagation(); onClick(); }} - title="View message path" - aria-label={`${hopInfo.display}, view path`} + title={widthLabel ? `View message path (${widthLabel} per hop)` : 'View message path'} + aria-label={`${hopInfo.display}${widthLabel ? `, ${widthLabel} per hop` : ''}, view path`} > {label} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index d16ad0a..3993146 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -26,6 +26,8 @@ import { import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; import { useRichPayloads } from '../../contexts/RichPayloadContext'; import { setSavedRenderRichPayloads } from '../../utils/richPayloadPreference'; +import { usePathHopWidth } from '../../contexts/PathHopWidthContext'; +import { setSavedShowPathHopWidth } from '../../utils/pathHopWidthPreference'; import { DEFAULT_FONT_SCALE, FONT_SCALE_SLIDER_STEP, @@ -233,6 +235,7 @@ export function SettingsLocalSection({ }) { const { distanceUnit, setDistanceUnit } = useDistanceUnit(); const { renderRichPayloads, setRenderRichPayloads } = useRichPayloads(); + const { showPathHopWidth, setShowPathHopWidth } = usePathHopWidth(); const [reopenLastConversation, setReopenLastConversation] = useState( getReopenLastConversationEnabled ); @@ -480,6 +483,27 @@ export function SettingsLocalSection({ +
+ { + const v = checked === true; + setShowPathHopWidth(v); + setSavedShowPathHopWidth(v); + }} + className="mt-0.5" + /> +
+ +

+ Append the per-hop identifier width to the hop-count badge on received messages — + e.g. (2 · 2B) for a 2-hop path with 2-byte + hops. Direct (0-hop) messages show no width. Off by default. +

+
+
+
void; +} + +const noop = () => {}; + +const PathHopWidthContext = createContext({ + showPathHopWidth: false, + setShowPathHopWidth: noop, +}); + +export function PathHopWidthProvider({ + showPathHopWidth, + setShowPathHopWidth, + children, +}: PathHopWidthContextValue & { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function usePathHopWidth() { + return useContext(PathHopWidthContext); +} diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index 2db16db..e601dd3 100644 --- a/frontend/src/hooks/useAppShell.ts +++ b/frontend/src/hooks/useAppShell.ts @@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits'; import { getSavedRenderRichPayloads } from '../utils/richPayloadPreference'; +import { getSavedShowPathHopWidth } from '../utils/pathHopWidthPreference'; import type { SettingsSection } from '../components/settings/settingsConstants'; import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash'; @@ -16,12 +17,14 @@ interface UseAppShellResult { localLabel: LocalLabel; distanceUnit: DistanceUnit; renderRichPayloads: boolean; + showPathHopWidth: boolean; setSettingsSection: (section: SettingsSection) => void; setSidebarOpen: (open: boolean) => void; setCrackerRunning: (running: boolean) => void; setLocalLabel: (label: LocalLabel) => void; setDistanceUnit: (unit: DistanceUnit) => void; setRenderRichPayloads: (enabled: boolean) => void; + setShowPathHopWidth: (enabled: boolean) => void; handleCloseSettingsView: () => void; handleToggleSettingsView: () => void; handleOpenNewMessage: () => void; @@ -42,6 +45,7 @@ export function useAppShell(): UseAppShellResult { const [localLabel, setLocalLabel] = useState(getLocalLabel); const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit); const [renderRichPayloads, setRenderRichPayloads] = useState(getSavedRenderRichPayloads); + const [showPathHopWidth, setShowPathHopWidth] = useState(getSavedShowPathHopWidth); const previousHashRef = useRef(''); const isOpeningSettingsRef = useRef(false); const pushedSettingsEntryRef = useRef(false); @@ -132,12 +136,14 @@ export function useAppShell(): UseAppShellResult { localLabel, distanceUnit, renderRichPayloads, + showPathHopWidth, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, setDistanceUnit, setRenderRichPayloads, + setShowPathHopWidth, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx index faed098..3097027 100644 --- a/frontend/src/test/messageList.test.tsx +++ b/frontend/src/test/messageList.test.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageList } from '../components/MessageList'; +import { PathHopWidthProvider } from '../contexts/PathHopWidthContext'; import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types'; const scrollIntoViewMock = vi.fn(); @@ -87,6 +88,66 @@ describe('MessageList channel sender rendering', () => { expect(screen.queryByText('nl-gr')).not.toBeInTheDocument(); }); + it('shows per-hop byte width in the path badge when the toggle is on', () => { + render( + {}}> + + + ); + + expect(screen.getByText('(2 · 2B)')).toBeInTheDocument(); + expect(screen.getByTitle('View message path (2B per hop)')).toBeInTheDocument(); + }); + + it('hides the width by default (toggle off) and shows only the hop count', () => { + render( + + ); + + expect(screen.getByText('(2)')).toBeInTheDocument(); + expect(screen.queryByText('(2 · 2B)')).not.toBeInTheDocument(); + expect(screen.getByTitle('View message path')).toBeInTheDocument(); + }); + + it('omits the width for direct (0-hop) paths even when the toggle is on', () => { + render( + {}}> + + + ); + + expect(screen.getByText('(d)')).toBeInTheDocument(); + expect(screen.getByTitle('View message path')).toBeInTheDocument(); + }); + it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => { render( { expect(result.hasMultiple).toBe(true); }); }); + +describe('formatPathHopWidths', () => { + it('returns null for null or empty paths', () => { + expect(formatPathHopWidths(null)).toBeNull(); + expect(formatPathHopWidths([])).toBeNull(); + }); + + it('returns null for direct (0-hop) paths', () => { + expect(formatPathHopWidths([{ path: '', received_at: 1700000000 }])).toBeNull(); + }); + + it('returns null for legacy paths without hop metadata', () => { + // No path_len -> width is not derivable, so we show nothing rather than guess. + expect(formatPathHopWidths([{ path: 'AABBCCDD', received_at: 1700000000 }])).toBeNull(); + }); + + it('derives 1-byte width', () => { + expect(formatPathHopWidths([{ path: '1A2B', path_len: 2, received_at: 1700000000 }])).toBe( + '1B' + ); + }); + + it('derives 2-byte width', () => { + expect(formatPathHopWidths([{ path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }])).toBe( + '2B' + ); + }); + + it('derives 3-byte width', () => { + expect( + formatPathHopWidths([{ path: 'AABBCCDDEEFF', path_len: 2, received_at: 1700000000 }]) + ).toBe('3B'); + }); + + it('dedupes identical widths across repeat paths', () => { + expect( + formatPathHopWidths([ + { path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }, + { path: '11223344', path_len: 2, received_at: 1700000001 }, + ]) + ).toBe('2B'); + }); + + it('ignores direct paths when other paths have a width', () => { + expect( + formatPathHopWidths([ + { path: '', received_at: 1700000000 }, + { path: 'AABBCCDD', path_len: 2, received_at: 1700000001 }, + ]) + ).toBe('2B'); + }); + + it('joins mixed widths sorted ascending', () => { + expect( + formatPathHopWidths([ + { path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }, // 2-byte + { path: '1A2B', path_len: 2, received_at: 1700000001 }, // 1-byte + ]) + ).toBe('1B/2B'); + }); +}); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 3b0cffd..9867099 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -22,6 +22,7 @@ import { } from '../utils/lastViewedConversation'; import { api } from '../api'; import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits'; +import { SHOW_PATH_HOP_WIDTH_KEY } from '../utils/pathHopWidthPreference'; import { DEFAULT_FONT_SCALE, FONT_SCALE_KEY, @@ -656,6 +657,19 @@ describe('SettingsModal', () => { expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull(); }); + it('defaults the path-hop-width toggle to off and persists enabling it', () => { + renderModal(); + openLocalSection(); + + const checkbox = screen.getByLabelText('Show Path Hop Width'); + expect(checkbox).not.toBeChecked(); + expect(localStorage.getItem(SHOW_PATH_HOP_WIDTH_KEY)).toBeNull(); + + fireEvent.click(checkbox); + + expect(localStorage.getItem(SHOW_PATH_HOP_WIDTH_KEY)).toBe('true'); + }); + it('defaults distance units to metric and stores local changes', () => { renderModal(); openLocalSection(); diff --git a/frontend/src/utils/pathHopWidthPreference.ts b/frontend/src/utils/pathHopWidthPreference.ts new file mode 100644 index 0000000..65e9cbc --- /dev/null +++ b/frontend/src/utils/pathHopWidthPreference.ts @@ -0,0 +1,25 @@ +// Browser-local preference for showing the per-hop byte width (e.g. "2B") +// next to the hop-count badge on received messages. Pure display tweak, stored +// per-browser in localStorage. Off by default. + +export const SHOW_PATH_HOP_WIDTH_KEY = 'remoteterm-show-path-hop-width'; + +export function getSavedShowPathHopWidth(): boolean { + try { + return localStorage.getItem(SHOW_PATH_HOP_WIDTH_KEY) === 'true'; + } catch { + return false; + } +} + +export function setSavedShowPathHopWidth(enabled: boolean): void { + try { + if (enabled) { + localStorage.setItem(SHOW_PATH_HOP_WIDTH_KEY, 'true'); + } else { + localStorage.removeItem(SHOW_PATH_HOP_WIDTH_KEY); + } + } catch { + // localStorage may be unavailable + } +} diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts index ed4dcfc..ca288e0 100644 --- a/frontend/src/utils/pathUtils.ts +++ b/frontend/src/utils/pathUtils.ts @@ -473,6 +473,35 @@ export function formatHopCounts(paths: MessagePath[] | null | undefined): { return { display, allDirect, hasMultiple }; } +/** + * Compact per-hop width label for a message's paths, e.g. "2B" (2 bytes per + * hop) or "1B/2B" when different receive paths used different hash widths. + * + * The width is derived from each path's hex length divided by its hop count, + * so it is only shown for paths that actually carry hop bytes with usable + * `path_len` metadata. Returns null when no path has a derivable width — direct + * (0-hop) paths and legacy rows without hop metadata contribute nothing. + */ +export function formatPathHopWidths(paths: MessagePath[] | null | undefined): string | null { + if (!paths || paths.length === 0) { + return null; + } + const bytesPerHop = new Set(); + for (const p of paths) { + const mode = inferPathHashMode(p.path, p.path_len); + if (mode != null) { + bytesPerHop.add(mode + 1); + } + } + if (bytesPerHop.size === 0) { + return null; + } + return [...bytesPerHop] + .sort((a, b) => a - b) + .map((w) => `${w}B`) + .join('/'); +} + /** * Build complete path resolution with sender, hops, and receiver */