Path display improvements, focusable maps, contact distance display, click to copy keys

This commit is contained in:
Jack Kingsman
2026-01-18 16:08:39 -08:00
parent 05a830d63f
commit cc1a2c57c2
14 changed files with 971 additions and 573 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-DlEnSqQ7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aLTdJARH.css">
<script type="module" crossorigin src="/assets/index-r2fyhyDF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DIRlMkt4.css">
</head>
<body>
<div id="root"></div>
+65 -6
View File
@@ -21,7 +21,8 @@ import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
import { formatTime } from './utils/messageParser';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { parseHashConversation, updateUrlHash } from './utils/urlHash';
import { parseHashConversation, updateUrlHash, getMapFocusHash } from './utils/urlHash';
import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils';
import { loadFavorites, toggleFavorite, isFavorite, type Favorite } from './utils/favorites';
import { cn } from '@/lib/utils';
import type {
@@ -121,6 +122,8 @@ export function App() {
toast.success('Radio connected', {
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
});
// Refresh config after reconnection (may have changed after reboot)
api.getRadioConfig().then(setConfig).catch(console.error);
} else {
toast.error('Radio disconnected', {
description: 'Check radio connection and power',
@@ -272,6 +275,14 @@ export function App() {
if (hashConv.type === 'raw') {
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
}
if (hashConv.type === 'map') {
return {
type: 'map',
id: 'map',
name: 'Node Map',
mapFocusKey: hashConv.mapFocusKey,
};
}
if (hashConv.type === 'channel') {
const channel = channels.find(
(c) => c.name === hashConv.name || c.name === `#${hashConv.name}`
@@ -588,7 +599,7 @@ export function App() {
Node Map
</div>
<div className="flex-1 overflow-hidden">
<MapView contacts={contacts} />
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</div>
</>
) : activeConversation.type === 'raw' ? (
@@ -612,8 +623,22 @@ export function App() {
: ''}
{activeConversation.name}
</span>
<span className="font-normal text-sm text-muted-foreground font-mono truncate">
{activeConversation.id}
<span
className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(activeConversation.id);
toast.success(
activeConversation.type === 'channel'
? 'Room key copied!'
: 'Contact key copied!'
);
}}
title="Click to copy"
>
{activeConversation.type === 'channel'
? activeConversation.id.toLowerCase()
: activeConversation.id}
</span>
{activeConversation.type === 'contact' &&
(() => {
@@ -621,7 +646,7 @@ export function App() {
(c) => c.public_key === activeConversation.id
);
if (!contact) return null;
const parts: string[] = [];
const parts: React.ReactNode[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
@@ -634,9 +659,43 @@ export function App() {
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
// Add coordinate link if contact has valid location
if (isValidLocation(contact.lat, contact.lon)) {
// Calculate distance from us if we have valid location
const distFromUs =
config && isValidLocation(config.lat, config.lon)
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
parts.push(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
const url =
window.location.origin +
window.location.pathname +
getMapFocusHash(contact.public_key);
window.open(url, '_blank');
}}
title="View on map"
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
({parts.join(', ')})
(
{parts.map((part, i) => (
<span key={i}>
{i > 0 && ', '}
{part}
</span>
))}
)
</span>
) : null;
})()}
+48 -6
View File
@@ -1,13 +1,16 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import type { LatLngBoundsExpression } from 'leaflet';
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { Contact } from '../types';
import { formatTime } from '../utils/messageParser';
import { CONTACT_TYPE_REPEATER } from '../types';
import { pubkeysMatch } from '../utils/pubkey';
interface MapViewProps {
contacts: Contact[];
/** Public key (or prefix) of contact to focus on and open popup */
focusedKey?: string | null;
}
// Calculate marker color based on how recently the contact was heard
@@ -24,11 +27,24 @@ function getMarkerColor(lastSeen: number): string {
}
// Component to handle map bounds fitting
function MapBoundsHandler({ contacts }: { contacts: Contact[] }) {
function MapBoundsHandler({
contacts,
focusedContact,
}: {
contacts: Contact[];
focusedContact: Contact | null;
}) {
const map = useMap();
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
// If we have a focused contact, center on it immediately (even if already initialized)
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
map.setView([focusedContact.lat, focusedContact.lon], 12);
setHasInitialized(true);
return;
}
if (hasInitialized) return;
const fitToContacts = () => {
@@ -72,12 +88,12 @@ function MapBoundsHandler({ contacts }: { contacts: Contact[] }) {
// No geolocation support - fit to contacts
fitToContacts();
}
}, [map, contacts, hasInitialized]);
}, [map, contacts, hasInitialized, focusedContact]);
return null;
}
export function MapView({ contacts }: MapViewProps) {
export function MapView({ contacts, focusedKey }: MapViewProps) {
// Filter to contacts with GPS coordinates, heard within the last 7 days
const mappableContacts = useMemo(() => {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
@@ -86,6 +102,31 @@ export function MapView({ contacts }: MapViewProps) {
);
}, [contacts]);
// Find the focused contact by key prefix
const focusedContact = useMemo(() => {
if (!focusedKey) return null;
return mappableContacts.find((c) => pubkeysMatch(c.public_key, focusedKey)) || null;
}, [focusedKey, mappableContacts]);
// Track marker refs to open popup programmatically
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
markerRefs.current[key] = ref;
}, []);
// Open popup for focused contact after map is ready
useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
// Small delay to ensure map has finished rendering
const timer = setTimeout(() => {
markerRefs.current[focusedContact.public_key]?.openPopup();
}, 100);
return () => clearTimeout(timer);
}
}, [focusedContact]);
return (
<div className="flex flex-col h-full">
{/* Info bar */}
@@ -122,7 +163,7 @@ export function MapView({ contacts }: MapViewProps) {
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBoundsHandler contacts={mappableContacts} />
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
{mappableContacts.map((contact) => {
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
@@ -132,6 +173,7 @@ export function MapView({ contacts }: MapViewProps) {
return (
<CircleMarker
key={contact.public_key}
ref={(ref) => setMarkerRef(contact.public_key, ref)}
center={[contact.lat!, contact.lon!]}
radius={isRepeater ? 10 : 7}
pathOptions={{
+97 -12
View File
@@ -12,10 +12,12 @@ import {
resolvePath,
calculateDistance,
isValidLocation,
formatDistance,
type SenderInfo,
type ResolvedPath,
type PathHop,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
interface PathModalProps {
open: boolean;
@@ -42,7 +44,7 @@ export function PathModal({ open, onClose, path, senderInfo, contacts, config }:
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
<PathVisualization resolved={resolved} />
<PathVisualization resolved={resolved} senderInfo={senderInfo} />
</div>
<DialogFooter>
@@ -55,9 +57,10 @@ export function PathModal({ open, onClose, path, senderInfo, contacts, config }:
interface PathVisualizationProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
}
function PathVisualization({ resolved }: PathVisualizationProps) {
function PathVisualization({ resolved, senderInfo }: 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 => {
@@ -93,6 +96,9 @@ function PathVisualization({ resolved }: PathVisualizationProps) {
prefix={resolved.sender.prefix}
distance={null}
isFirst
lat={resolved.sender.lat}
lon={resolved.sender.lon}
publicKey={senderInfo.publicKeyOrPrefix}
/>
{/* Hops */}
@@ -112,6 +118,9 @@ function PathVisualization({ resolved }: PathVisualizationProps) {
prefix={resolved.receiver.prefix}
distance={calculateReceiverDistance(resolved)}
isLast
lat={resolved.receiver.lat}
lon={resolved.receiver.lon}
publicKey={resolved.receiver.publicKey ?? undefined}
/>
{/* Total distance */}
@@ -120,9 +129,36 @@ function PathVisualization({ resolved }: PathVisualizationProps) {
<span className="text-sm text-muted-foreground">
Presumed unambiguous distance covered:{' '}
</span>
<span className="text-sm font-medium">{formatDistance(resolved.totalDistances[0])}</span>
<span className="text-sm font-medium">
{resolved.hasGaps ? '>' : ''}
{formatDistance(resolved.totalDistances[0])}
</span>
</div>
)}
{/* Straight-line distance (when both sender and receiver have coordinates) */}
{isValidLocation(resolved.sender.lat, resolved.sender.lon) &&
isValidLocation(resolved.receiver.lat, resolved.receiver.lon) && (
<div
className={
resolved.totalDistances && resolved.totalDistances.length > 0
? 'pt-1'
: 'pt-3 mt-3 border-t border-border'
}
>
<span className="text-sm text-muted-foreground">Straight-line distance: </span>
<span className="text-sm font-medium">
{formatDistance(
calculateDistance(
resolved.sender.lat,
resolved.sender.lon,
resolved.receiver.lat,
resolved.receiver.lon
)!
)}
</span>
</div>
)}
</div>
);
}
@@ -134,9 +170,26 @@ interface PathNodeProps {
distance: number | null;
isFirst?: boolean;
isLast?: boolean;
/** Optional coordinates for map link */
lat?: number | null;
lon?: number | null;
/** Public key for map focus link (required if lat/lon provided) */
publicKey?: string;
}
function PathNode({ label, name, prefix, distance, isFirst, isLast }: PathNodeProps) {
function PathNode({
label,
name,
prefix,
distance,
isFirst,
isLast,
lat,
lon,
publicKey,
}: PathNodeProps) {
const hasLocation = isValidLocation(lat ?? null, lon ?? null) && publicKey;
return (
<div className="flex gap-3">
{/* Vertical line and dot column */}
@@ -151,10 +204,11 @@ function PathNode({ label, name, prefix, distance, isFirst, isLast }: PathNodePr
<div className="text-xs text-muted-foreground font-medium">{label}</div>
<div className="font-medium truncate">
{name} <span className="text-muted-foreground font-mono text-sm">({prefix})</span>
{distance !== null && (
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
)}
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
</div>
{distance !== null && (
<div className="text-xs text-muted-foreground">{formatDistance(distance)}</div>
)}
</div>
</div>
);
@@ -210,6 +264,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
<div>
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}{' '}
@@ -221,6 +276,13 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
- {formatDistance(dist)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
})}
@@ -234,6 +296,13 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
- {formatDistance(hop.distanceFromPrev)}
</span>
)}
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
<CoordinateLink
lat={hop.matches[0].lat!}
lon={hop.matches[0].lon!}
publicKey={hop.matches[0].public_key}
/>
)}
</div>
)}
</div>
@@ -241,11 +310,27 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
);
}
function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
}
return `${km.toFixed(1)}km`;
/**
* Render clickable coordinates that open the map focused on the contact
*/
function CoordinateLink({ lat, lon, publicKey }: { lat: number; lon: number; publicKey: string }) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Open map in new tab with focus on this contact
const url = window.location.origin + window.location.pathname + getMapFocusHash(publicKey);
window.open(url, '_blank');
};
return (
<span
className="text-xs text-muted-foreground/70 font-mono cursor-pointer hover:text-primary hover:underline ml-1"
onClick={handleClick}
title="View on map"
>
({lat.toFixed(4)}, {lon.toFixed(4)})
</span>
);
}
function calculateReceiverDistance(resolved: ResolvedPath): number | null {
+99
View File
@@ -6,6 +6,7 @@ import {
sortContactsByDistance,
getHopCount,
resolvePath,
formatDistance,
} from '../utils/pathUtils';
import type { Contact, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_CLIENT } from '../types';
@@ -473,4 +474,102 @@ describe('resolvePath', () => {
// But second hop CAN have distance (from first hop)
expect(result.hops[1].distanceFromPrev).not.toBeNull();
});
it('sets hasGaps to false when all hops are unambiguous with locations', () => {
const result = resolvePath('1A2B', sender, contacts, config);
expect(result.hasGaps).toBe(false);
});
it('sets hasGaps to true when path has unknown hops', () => {
const result = resolvePath('XX', sender, contacts, config);
expect(result.hasGaps).toBe(true);
});
it('sets hasGaps to true when path has ambiguous hops', () => {
const ambiguousContacts = [
createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'Repeater1A',
type: CONTACT_TYPE_REPEATER,
lat: 40.75,
lon: -74.0,
}),
createContact({
public_key: '1A' + 'B'.repeat(62),
name: 'Repeater1B',
type: CONTACT_TYPE_REPEATER,
lat: 40.76,
lon: -73.99,
}),
];
const result = resolvePath('1A', sender, ambiguousContacts, config);
expect(result.hasGaps).toBe(true);
});
it('sets hasGaps to true when sender has no location', () => {
const senderNoLocation = {
name: 'SenderNoLoc',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: null,
lon: null,
};
const result = resolvePath('1A', senderNoLocation, contacts, config);
expect(result.hasGaps).toBe(true);
});
it('sets hasGaps to true when receiver has no valid location', () => {
const configNoLocation = createConfig({
public_key: 'FF' + 'F'.repeat(62),
name: 'MyRadio',
lat: 0,
lon: 0,
});
const result = resolvePath('1A', sender, contacts, configNoLocation);
expect(result.hasGaps).toBe(true);
});
it('includes receiver public key when config has one', () => {
const result = resolvePath('1A', sender, contacts, config);
expect(result.receiver.publicKey).toBe(config.public_key);
});
it('sets receiver public key to null when config has no public key', () => {
const configNoKey = createConfig({
public_key: undefined as unknown as string,
name: 'NoKeyRadio',
});
const result = resolvePath('1A', sender, contacts, configNoKey);
expect(result.receiver.publicKey).toBeNull();
});
});
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');
});
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('rounds meters to nearest integer', () => {
expect(formatDistance(0.4567)).toBe('457m');
expect(formatDistance(0.001)).toBe('1m');
});
});
+65 -1
View File
@@ -6,7 +6,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseHashConversation, getConversationHash } from '../utils/urlHash';
import { parseHashConversation, getConversationHash, getMapFocusHash } from '../utils/urlHash';
import type { Conversation } from '../types';
describe('parseHashConversation', () => {
@@ -36,6 +36,38 @@ describe('parseHashConversation', () => {
expect(result).toEqual({ type: 'raw', name: 'raw' });
});
it('parses #map as map type', () => {
window.location.hash = '#map';
const result = parseHashConversation();
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('parses #map/focus/PUBKEY with focus key', () => {
window.location.hash = '#map/focus/ABCD1234';
const result = parseHashConversation();
expect(result).toEqual({ type: 'map', name: 'map', mapFocusKey: 'ABCD1234' });
});
it('parses #map/focus/ with empty focus as plain map', () => {
window.location.hash = '#map/focus/';
const result = parseHashConversation();
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('decodes URL-encoded map focus key', () => {
window.location.hash = '#map/focus/AB%20CD';
const result = parseHashConversation();
expect(result).toEqual({ type: 'map', name: 'map', mapFocusKey: 'AB CD' });
});
it('parses channel hash', () => {
window.location.hash = '#channel/Public';
@@ -108,6 +140,14 @@ describe('getConversationHash', () => {
expect(result).toBe('#raw');
});
it('returns #map for map conversation', () => {
const conv: Conversation = { type: 'map', id: 'map', name: 'Node Map' };
const result = getConversationHash(conv);
expect(result).toBe('#map');
});
it('generates channel hash', () => {
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Public' };
@@ -189,4 +229,28 @@ describe('parseHashConversation and getConversationHash roundtrip', () => {
expect(parsed).toEqual({ type: 'raw', name: 'raw' });
});
it('map roundtrip preserves type', () => {
const conv: Conversation = { type: 'map', id: 'map', name: 'Node Map' };
const hash = getConversationHash(conv);
window.location.hash = hash;
const parsed = parseHashConversation();
expect(parsed).toEqual({ type: 'map', name: 'map' });
});
});
describe('getMapFocusHash', () => {
it('generates hash with focus key', () => {
const result = getMapFocusHash('ABCD1234');
expect(result).toBe('#map/focus/ABCD1234');
});
it('encodes special characters in key', () => {
const result = getMapFocusHash('AB CD/12');
expect(result).toBe('#map/focus/AB%20CD%2F12');
});
});
+2
View File
@@ -98,6 +98,8 @@ export interface Conversation {
/** PublicKey for contacts, ChannelKey for channels, 'raw'/'map' for special views */
id: string;
name: string;
/** For map view: public key prefix to focus on */
mapFocusKey?: string;
}
export interface RawPacket {
+29 -1
View File
@@ -10,8 +10,16 @@ export interface PathHop {
export interface ResolvedPath {
sender: { name: string; prefix: string; lat: number | null; lon: number | null };
hops: PathHop[];
receiver: { name: string; prefix: string; lat: number | null; lon: number | null };
receiver: {
name: string;
prefix: string;
lat: number | null;
lon: number | null;
publicKey: string | null;
};
totalDistances: number[] | null; // Single-element array with sum of unambiguous distances
/** True if path has any gaps (unknown, ambiguous, or missing location hops) */
hasGaps: boolean;
}
export interface SenderInfo {
@@ -101,6 +109,16 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean
return true;
}
/**
* Format distance in human-readable form (m or km)
*/
export function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
}
return `${km.toFixed(1)}km`;
}
/**
* Sort contacts by distance from a reference point
* Contacts without location are placed at the end
@@ -164,6 +182,7 @@ export function resolvePath(
prefix: receiverPrefix,
lat: config?.lat ?? null,
lon: config?.lon ?? null,
publicKey: config?.public_key ?? null,
};
// Build hops
@@ -229,11 +248,20 @@ export function resolvePath(
// Calculate total distances (can be multiple if ambiguous)
const totalDistances = calculateTotalDistances(resolvedSender, hops, resolvedReceiver);
// Determine if path has any gaps (unknown, ambiguous, or missing location)
const hasGaps =
!isValidLocation(resolvedSender.lat, resolvedSender.lon) ||
!isValidLocation(resolvedReceiver.lat, resolvedReceiver.lon) ||
hops.some(
(hop) => hop.matches.length !== 1 || !isValidLocation(hop.matches[0].lat, hop.matches[0].lon)
);
return {
sender: resolvedSender,
hops,
receiver: resolvedReceiver,
totalDistances,
hasGaps,
};
}
+20 -1
View File
@@ -3,9 +3,11 @@ import type { Conversation } from '../types';
export interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map';
name: string;
/** For map view: public key prefix to focus on */
mapFocusKey?: string;
}
// Parse URL hash to get conversation (e.g., #channel/Public or #contact/JohnDoe or #raw)
// Parse URL hash to get conversation (e.g., #channel/Public or #contact/JohnDoe or #raw or #map/focus/ABCD1234)
export function parseHashConversation(): ParsedHashConversation | null {
const hash = window.location.hash.slice(1); // Remove leading #
if (!hash) return null;
@@ -18,6 +20,15 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'map', name: 'map' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
if (focusKey) {
return { type: 'map', name: 'map', mapFocusKey: decodeURIComponent(focusKey) };
}
return { type: 'map', name: 'map' };
}
const slashIndex = hash.indexOf('/');
if (slashIndex === -1) return null;
@@ -30,6 +41,14 @@ export function parseHashConversation(): ParsedHashConversation | null {
return null;
}
/**
* Generate a URL hash for focusing on a contact in the map view
* @param publicKeyPrefix - The public key or prefix to focus on
*/
export function getMapFocusHash(publicKeyPrefix: string): string {
return `#map/focus/${encodeURIComponent(publicKeyPrefix)}`;
}
// Generate URL hash from conversation
export function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';