Add hop width bit display display. Closes #323.

This commit is contained in:
Jack Kingsman
2026-07-10 15:23:46 -07:00
parent f8f6842d1d
commit d2e3b05a38
10 changed files with 293 additions and 32 deletions
+36 -28
View File
@@ -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}
>
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
<PathHopWidthProvider
showPathHopWidth={showPathHopWidth}
setShowPathHopWidth={setShowPathHopWidth}
>
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
</PathHopWidthProvider>
</RichPayloadProvider>
</DistanceUnitProvider>
);
+7 -4
View File
@@ -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}
</span>
@@ -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({
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="show-path-hop-width"
checked={showPathHopWidth}
onCheckedChange={(checked) => {
const v = checked === true;
setShowPathHopWidth(v);
setSavedShowPathHopWidth(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="show-path-hop-width">Show Path Hop Width</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Append the per-hop identifier width to the hop-count badge on received messages
e.g. <code className="text-[0.75rem]">(2 · 2B)</code> for a 2-hop path with 2-byte
hops. Direct (0-hop) messages show no width. Off by default.
</p>
</div>
</div>
<div className="rounded-md border border-border/60 p-3 space-y-2">
<div className="flex items-start gap-3">
<Checkbox
@@ -0,0 +1,29 @@
import { createContext, useContext, type ReactNode } from 'react';
interface PathHopWidthContextValue {
showPathHopWidth: boolean;
setShowPathHopWidth: (enabled: boolean) => void;
}
const noop = () => {};
const PathHopWidthContext = createContext<PathHopWidthContextValue>({
showPathHopWidth: false,
setShowPathHopWidth: noop,
});
export function PathHopWidthProvider({
showPathHopWidth,
setShowPathHopWidth,
children,
}: PathHopWidthContextValue & { children: ReactNode }) {
return (
<PathHopWidthContext.Provider value={{ showPathHopWidth, setShowPathHopWidth }}>
{children}
</PathHopWidthContext.Provider>
);
}
export function usePathHopWidth() {
return useContext(PathHopWidthContext);
}
+6
View File
@@ -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,
+61
View File
@@ -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(
<PathHopWidthProvider showPathHopWidth setShowPathHopWidth={() => {}}>
<MessageList
messages={[
createMessage({
sender_name: 'Alice',
// 8 hex chars over 2 hops = 2 bytes/hop.
paths: [{ path: 'AABBCCDD', path_len: 2, received_at: 1700000001 }],
}),
]}
contacts={[]}
loading={false}
/>
</PathHopWidthProvider>
);
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(
<MessageList
messages={[
createMessage({
sender_name: 'Alice',
paths: [{ path: 'AABBCCDD', path_len: 2, received_at: 1700000001 }],
}),
]}
contacts={[]}
loading={false}
/>
);
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(
<PathHopWidthProvider showPathHopWidth setShowPathHopWidth={() => {}}>
<MessageList
messages={[
createMessage({
sender_name: 'Alice',
paths: [{ path: '', path_len: 0, received_at: 1700000001 }],
}),
]}
contacts={[]}
loading={false}
/>
</PathHopWidthProvider>
);
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(
<MessageList
+62
View File
@@ -11,6 +11,7 @@ import {
resolvePath,
formatDistance,
formatHopCounts,
formatPathHopWidths,
} from '../utils/pathUtils';
import type { Contact, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
@@ -816,3 +817,64 @@ describe('formatHopCounts', () => {
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');
});
});
+14
View File
@@ -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();
@@ -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
}
}
+29
View File
@@ -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<number>();
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
*/