mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Add hop width bit display display. Closes #323.
This commit is contained in:
+36
-28
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user