mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
GIVE ME SMOOTS. Closes #87.
This commit is contained in:
+29
-24
@@ -18,6 +18,7 @@ import {
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
@@ -91,10 +92,12 @@ export function App() {
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
@@ -564,29 +567,31 @@ export function App() {
|
||||
setContactsLoaded,
|
||||
]);
|
||||
return (
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import type {
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -82,6 +83,7 @@ export function ContactInfoPane({
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
}: ContactInfoPaneProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||
|
||||
@@ -315,7 +317,7 @@ export function ContactInfoPane({
|
||||
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
||||
)}
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
|
||||
)}
|
||||
{effectiveRoute && (
|
||||
<InfoItem
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
||||
|
||||
interface ContactStatusInfoProps {
|
||||
@@ -24,6 +25,7 @@ interface ContactStatusInfoProps {
|
||||
* shared between ChatHeader and RepeaterDashboard.
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||
const parts: ReactNode[] = [];
|
||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||
@@ -74,7 +76,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
>
|
||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||
</span>
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '../utils/pathUtils';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||
|
||||
const PathRouteMap = lazy(() =>
|
||||
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
|
||||
@@ -44,6 +46,7 @@ export function PathModal({
|
||||
isResendable,
|
||||
onResend,
|
||||
}: PathModalProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
@@ -120,7 +123,8 @@ export function PathModal({
|
||||
resolvedPaths[0].resolved.sender.lon,
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
)!
|
||||
)!,
|
||||
distanceUnit
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -171,7 +175,11 @@ export function PathModal({
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
<PathVisualization
|
||||
resolved={pathData.resolved}
|
||||
senderInfo={senderInfo}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -227,9 +235,10 @@ export function PathModal({
|
||||
interface PathVisualizationProps {
|
||||
resolved: ResolvedPath;
|
||||
senderInfo: SenderInfo;
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
|
||||
// Track previous location for each hop to calculate distances
|
||||
// Returns null if previous hop was ambiguous or has invalid location
|
||||
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
|
||||
@@ -264,6 +273,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
name={resolved.sender.name}
|
||||
prefix={resolved.sender.prefix}
|
||||
distance={null}
|
||||
distanceUnit={distanceUnit}
|
||||
isFirst
|
||||
lat={resolved.sender.lat}
|
||||
lon={resolved.sender.lon}
|
||||
@@ -277,6 +287,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
hop={hop}
|
||||
hopNumber={index + 1}
|
||||
prevLocation={getPrevLocation(index)}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -286,6 +297,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
name={resolved.receiver.name}
|
||||
prefix={resolved.receiver.prefix}
|
||||
distance={calculateReceiverDistance(resolved)}
|
||||
distanceUnit={distanceUnit}
|
||||
isLast
|
||||
lat={resolved.receiver.lat}
|
||||
lon={resolved.receiver.lon}
|
||||
@@ -300,7 +312,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{resolved.hasGaps ? '>' : ''}
|
||||
{formatDistance(resolved.totalDistances[0])}
|
||||
{formatDistance(resolved.totalDistances[0], distanceUnit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -313,6 +325,7 @@ interface PathNodeProps {
|
||||
name: string;
|
||||
prefix: string;
|
||||
distance: number | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
/** Optional coordinates for map link */
|
||||
@@ -327,6 +340,7 @@ function PathNode({
|
||||
name,
|
||||
prefix,
|
||||
distance,
|
||||
distanceUnit,
|
||||
isFirst,
|
||||
isLast,
|
||||
lat,
|
||||
@@ -353,7 +367,9 @@ function PathNode({
|
||||
<div className="font-medium truncate">
|
||||
{name}
|
||||
{distance !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(distance, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
|
||||
</div>
|
||||
@@ -366,9 +382,10 @@ interface HopNodeProps {
|
||||
hop: PathHop;
|
||||
hopNumber: number;
|
||||
prevLocation: { lat: number | null; lon: number | null } | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
const isAmbiguous = hop.matches.length > 1;
|
||||
const isUnknown = hop.matches.length === 0;
|
||||
|
||||
@@ -417,7 +434,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist)}
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
@@ -436,7 +453,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
|
||||
{hop.distanceFromPrev !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(hop.distanceFromPrev)}
|
||||
- {formatDistance(hop.distanceFromPrev, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import type {
|
||||
Contact,
|
||||
RepeaterNeighborsResponse,
|
||||
@@ -35,6 +36,7 @@ export function NeighborsPane({
|
||||
nodeInfoState: PaneState;
|
||||
repeaterName: string | null;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const advertLat = repeaterContact?.lat ?? null;
|
||||
const advertLon = repeaterContact?.lon ?? null;
|
||||
|
||||
@@ -93,7 +95,7 @@ export function NeighborsPane({
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
dist = formatDistance(distKm, distanceUnit);
|
||||
anyDist = true;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export function NeighborsPane({
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from '../../utils/lastViewedConversation';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
|
||||
import {
|
||||
DISTANCE_UNIT_LABELS,
|
||||
DISTANCE_UNITS,
|
||||
setSavedDistanceUnit,
|
||||
} from '../../utils/distanceUnits';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -19,6 +25,7 @@ export function SettingsLocalSection({
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
@@ -82,6 +89,31 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
id="distance-units"
|
||||
value={distanceUnit}
|
||||
onChange={(event) => {
|
||||
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
|
||||
setSavedDistanceUnit(nextUnit);
|
||||
setDistanceUnit(nextUnit);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{DISTANCE_UNITS.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{DISTANCE_UNIT_LABELS[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls how distances are shown throughout the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||
|
||||
interface DistanceUnitContextValue {
|
||||
distanceUnit: DistanceUnit;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const DistanceUnitContext = createContext<DistanceUnitContextValue>({
|
||||
distanceUnit: 'imperial',
|
||||
setDistanceUnit: noop,
|
||||
});
|
||||
|
||||
export function DistanceUnitProvider({
|
||||
distanceUnit,
|
||||
setDistanceUnit,
|
||||
children,
|
||||
}: DistanceUnitContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<DistanceUnitContext.Provider value={{ distanceUnit, setDistanceUnit }}>
|
||||
{children}
|
||||
</DistanceUnitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDistanceUnit() {
|
||||
return useContext(DistanceUnitContext);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
|
||||
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
|
||||
|
||||
@@ -12,10 +13,12 @@ interface UseAppShellResult {
|
||||
showCracker: boolean;
|
||||
crackerRunning: boolean;
|
||||
localLabel: LocalLabel;
|
||||
distanceUnit: DistanceUnit;
|
||||
setSettingsSection: (section: SettingsSection) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCrackerRunning: (running: boolean) => void;
|
||||
setLocalLabel: (label: LocalLabel) => void;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
handleCloseSettingsView: () => void;
|
||||
handleToggleSettingsView: () => void;
|
||||
handleOpenNewMessage: () => void;
|
||||
@@ -34,6 +37,7 @@ export function useAppShell(): UseAppShellResult {
|
||||
const [showCracker, setShowCracker] = useState(false);
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
|
||||
const previousHashRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,10 +91,12 @@ export function useAppShell(): UseAppShellResult {
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DISTANCE_UNIT_KEY,
|
||||
getSavedDistanceUnit,
|
||||
setSavedDistanceUnit,
|
||||
} from '../utils/distanceUnits';
|
||||
|
||||
describe('distanceUnits utilities', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to imperial when unset', () => {
|
||||
expect(getSavedDistanceUnit()).toBe('imperial');
|
||||
});
|
||||
|
||||
it('returns the stored unit when valid', () => {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, 'metric');
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('falls back to imperial for invalid stored values', () => {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, 'parsecs');
|
||||
expect(getSavedDistanceUnit()).toBe('imperial');
|
||||
});
|
||||
|
||||
it('stores the selected distance unit', () => {
|
||||
setSavedDistanceUnit('smoots');
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
});
|
||||
@@ -685,22 +685,39 @@ describe('isValidLocation', () => {
|
||||
});
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('formats distances under 1km in meters', () => {
|
||||
expect(formatDistance(0.5)).toBe('500m');
|
||||
expect(formatDistance(0.123)).toBe('123m');
|
||||
expect(formatDistance(0.9999)).toBe('1000m');
|
||||
const formatInteger = (value: number) => value.toLocaleString();
|
||||
const formatOneDecimal = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
it('defaults to imperial formatting', () => {
|
||||
expect(formatDistance(0.01)).toBe(`${formatInteger(33)}ft`);
|
||||
expect(formatDistance(0.5)).toBe(`${formatOneDecimal(0.5 * 0.621371)}mi`);
|
||||
expect(formatDistance(1)).toBe(`${formatOneDecimal(0.621371)}mi`);
|
||||
});
|
||||
|
||||
it('formats distances at or above 1km with one decimal', () => {
|
||||
expect(formatDistance(1)).toBe('1.0km');
|
||||
expect(formatDistance(1.5)).toBe('1.5km');
|
||||
expect(formatDistance(12.34)).toBe('12.3km');
|
||||
expect(formatDistance(100)).toBe('100.0km');
|
||||
it('formats metric distances in meters and kilometers', () => {
|
||||
expect(formatDistance(0.5, 'metric')).toBe(`${formatInteger(500)}m`);
|
||||
expect(formatDistance(0.123, 'metric')).toBe(`${formatInteger(123)}m`);
|
||||
expect(formatDistance(0.9999, 'metric')).toBe(`${formatInteger(1000)}m`);
|
||||
expect(formatDistance(1, 'metric')).toBe(`${formatOneDecimal(1)}km`);
|
||||
expect(formatDistance(12.34, 'metric')).toBe(`${formatOneDecimal(12.34)}km`);
|
||||
});
|
||||
|
||||
it('rounds meters to nearest integer', () => {
|
||||
expect(formatDistance(0.4567)).toBe('457m');
|
||||
expect(formatDistance(0.001)).toBe('1m');
|
||||
it('formats smoot distances using 1.7018 meters per smoot', () => {
|
||||
expect(formatDistance(0.0017018, 'smoots')).toBe(`${formatOneDecimal(1)} smoot`);
|
||||
expect(formatDistance(0.001, 'smoots')).toBe(`${formatOneDecimal(0.6)} smoots`);
|
||||
expect(formatDistance(1, 'smoots')).toBe(`${formatInteger(588)} smoots`);
|
||||
});
|
||||
|
||||
it('applies locale separators to large values', () => {
|
||||
expect(formatDistance(1.234, 'metric')).toBe(`${formatOneDecimal(1.234)}km`);
|
||||
expect(formatDistance(1234, 'metric')).toBe(`${formatOneDecimal(1234)}km`);
|
||||
expect(formatDistance(2.1, 'smoots')).toContain(
|
||||
formatInteger(Math.round((2.1 * 1000) / 1.7018))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
REOPEN_LAST_CONVERSATION_KEY,
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { api } from '../api';
|
||||
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
||||
|
||||
const baseConfig: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -509,6 +510,18 @@ describe('SettingsModal', () => {
|
||||
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults distance units to imperial and stores local changes', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
const select = screen.getByLabelText('Distance Units');
|
||||
expect(select).toHaveValue('imperial');
|
||||
|
||||
fireEvent.change(select, { target: { value: 'smoots' } });
|
||||
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
|
||||
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
||||
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
||||
packets_deleted: 12,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export const DISTANCE_UNIT_KEY = 'remoteterm-distance-unit';
|
||||
|
||||
export const DISTANCE_UNITS = ['imperial', 'metric', 'smoots'] as const;
|
||||
|
||||
export type DistanceUnit = (typeof DISTANCE_UNITS)[number];
|
||||
|
||||
export const DISTANCE_UNIT_LABELS: Record<DistanceUnit, string> = {
|
||||
imperial: 'Imperial',
|
||||
metric: 'Metric',
|
||||
smoots: 'Smoots',
|
||||
};
|
||||
|
||||
function isDistanceUnit(value: unknown): value is DistanceUnit {
|
||||
return typeof value === 'string' && DISTANCE_UNITS.includes(value as DistanceUnit);
|
||||
}
|
||||
|
||||
export function getSavedDistanceUnit(): DistanceUnit {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISTANCE_UNIT_KEY);
|
||||
return isDistanceUnit(raw) ? raw : 'imperial';
|
||||
} catch {
|
||||
return 'imperial';
|
||||
}
|
||||
}
|
||||
|
||||
export function setSavedDistanceUnit(unit: DistanceUnit): void {
|
||||
try {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, unit);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types';
|
||||
import type { DistanceUnit } from './distanceUnits';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
const MAX_PATH_BYTES = 64;
|
||||
@@ -343,13 +344,35 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance in human-readable form (m or km)
|
||||
* Format distance in human-readable form using the selected display unit.
|
||||
*/
|
||||
export function formatDistance(km: number): string {
|
||||
if (km < 1) {
|
||||
return `${Math.round(km * 1000)}m`;
|
||||
export function formatDistance(km: number, unit: DistanceUnit = 'imperial'): string {
|
||||
const formatInteger = (value: number) => value.toLocaleString();
|
||||
const formatOneDecimal = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
if (unit === 'metric') {
|
||||
if (km < 1) {
|
||||
return `${formatInteger(Math.round(km * 1000))}m`;
|
||||
}
|
||||
return `${formatOneDecimal(km)}km`;
|
||||
}
|
||||
return `${km.toFixed(1)}km`;
|
||||
|
||||
if (unit === 'smoots') {
|
||||
const smoots = (km * 1000) / 1.7018;
|
||||
const rounded = smoots < 10 ? Number(smoots.toFixed(1)) : Math.round(smoots);
|
||||
const display = smoots < 10 ? formatOneDecimal(rounded) : formatInteger(rounded);
|
||||
return `${display} ${rounded === 1 ? 'smoot' : 'smoots'}`;
|
||||
}
|
||||
|
||||
const miles = km * 0.621371;
|
||||
if (miles < 0.1) {
|
||||
return `${formatInteger(Math.round(km * 3280.839895))}ft`;
|
||||
}
|
||||
return `${formatOneDecimal(miles)}mi`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user