diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44a7de0..a571db6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,13 @@ import { } from './utils/conversationState'; import { formatTime } from './utils/messageParser'; import { getContactDisplayName } from './utils/pubkey'; -import { parseHashConversation, updateUrlHash, getMapFocusHash } from './utils/urlHash'; +import { + parseHashConversation, + updateUrlHash, + getMapFocusHash, + resolveChannelFromHashToken, + resolveContactFromHashToken, +} from './utils/urlHash'; import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils'; import { isFavorite, @@ -425,11 +431,9 @@ export function App() { return; } - // Handle channel hash + // Handle channel hash (ID-first with legacy-name fallback) if (hashConv?.type === 'channel') { - const channel = channels.find( - (c) => c.name === hashConv.name || c.name === `#${hashConv.name}` - ); + const channel = resolveChannelFromHashToken(hashConv.name, channels); if (channel) { setActiveConversation({ type: 'channel', id: channel.key, name: channel.name }); hasSetDefaultConversation.current = true; @@ -459,9 +463,7 @@ export function App() { const hashConv = parseHashConversation(); if (hashConv?.type === 'contact') { - const contact = contacts.find( - (c) => getContactDisplayName(c.name, c.public_key) === hashConv.name - ); + const contact = resolveContactFromHashToken(hashConv.name, contacts); if (contact) { setActiveConversation({ type: 'contact', diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts index 295b09c..4d71bd5 100644 --- a/frontend/src/test/urlHash.test.ts +++ b/frontend/src/test/urlHash.test.ts @@ -6,8 +6,14 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { parseHashConversation, getConversationHash, getMapFocusHash } from '../utils/urlHash'; -import type { Conversation } from '../types'; +import { + parseHashConversation, + getConversationHash, + getMapFocusHash, + resolveChannelFromHashToken, + resolveContactFromHashToken, +} from '../utils/urlHash'; +import type { Channel, Contact, Conversation } from '../types'; describe('parseHashConversation', () => { let originalHash: string; @@ -69,19 +75,35 @@ describe('parseHashConversation', () => { }); it('parses channel hash', () => { - window.location.hash = '#channel/Public'; + window.location.hash = '#channel/ABCDEF0123456789ABCDEF0123456789'; const result = parseHashConversation(); - expect(result).toEqual({ type: 'channel', name: 'Public' }); + expect(result).toEqual({ type: 'channel', name: 'ABCDEF0123456789ABCDEF0123456789' }); }); it('parses contact hash', () => { - window.location.hash = '#contact/Alice'; + window.location.hash = + '#contact/abc123def4567890abc123def4567890abc123def4567890abc123def4567890'; const result = parseHashConversation(); - expect(result).toEqual({ type: 'contact', name: 'Alice' }); + expect(result).toEqual({ + type: 'contact', + name: 'abc123def4567890abc123def4567890abc123def4567890abc123def4567890', + }); + }); + + it('parses id plus readable label and preserves id token', () => { + window.location.hash = '#channel/ABCDEF0123456789ABCDEF0123456789/Public%20Room'; + + const result = parseHashConversation(); + + expect(result).toEqual({ + type: 'channel', + name: 'ABCDEF0123456789ABCDEF0123456789', + label: 'Public Room', + }); }); it('decodes URL-encoded names', () => { @@ -153,7 +175,7 @@ describe('getConversationHash', () => { const result = getConversationHash(conv); - expect(result).toBe('#channel/Public'); + expect(result).toBe('#channel/key123/Public'); }); it('generates contact hash', () => { @@ -161,31 +183,31 @@ describe('getConversationHash', () => { const result = getConversationHash(conv); - expect(result).toBe('#contact/Alice'); + expect(result).toBe('#contact/pubkey123/Alice'); }); - it('strips leading # from channel names', () => { + it('uses channel id even when name starts with #', () => { const conv: Conversation = { type: 'channel', id: 'key123', name: '#TestChannel' }; const result = getConversationHash(conv); - expect(result).toBe('#channel/TestChannel'); + expect(result).toBe('#channel/key123/TestChannel'); }); - it('encodes special characters in names', () => { - const conv: Conversation = { type: 'contact', id: 'key', name: 'John Doe' }; + it('encodes special characters in ids', () => { + const conv: Conversation = { type: 'contact', id: 'key with space', name: 'John Doe' }; const result = getConversationHash(conv); - expect(result).toBe('#contact/John%20Doe'); + expect(result).toBe('#contact/key%20with%20space/John%20Doe'); }); - it('does not strip # from contact names', () => { + it('uses id regardless of contact display name', () => { const conv: Conversation = { type: 'contact', id: 'key', name: '#Hashtag' }; const result = getConversationHash(conv); - expect(result).toBe('#contact/%23Hashtag'); + expect(result).toBe('#contact/key/%23Hashtag'); }); }); @@ -207,7 +229,7 @@ describe('parseHashConversation and getConversationHash roundtrip', () => { window.location.hash = hash; const parsed = parseHashConversation(); - expect(parsed).toEqual({ type: 'channel', name: 'Test Channel' }); + expect(parsed).toEqual({ type: 'channel', name: 'key123', label: 'Test Channel' }); }); it('contact roundtrip preserves data', () => { @@ -217,7 +239,7 @@ describe('parseHashConversation and getConversationHash roundtrip', () => { window.location.hash = hash; const parsed = parseHashConversation(); - expect(parsed).toEqual({ type: 'contact', name: 'Alice Bob' }); + expect(parsed).toEqual({ type: 'contact', name: 'pubkey', label: 'Alice Bob' }); }); it('raw roundtrip preserves type', () => { @@ -241,6 +263,124 @@ describe('parseHashConversation and getConversationHash roundtrip', () => { }); }); +describe('resolveChannelFromHashToken', () => { + const channels: Channel[] = [ + { + key: 'ABCDEF0123456789ABCDEF0123456789', + name: 'Public', + is_hashtag: false, + on_radio: true, + last_read_at: null, + }, + { + key: '11111111111111111111111111111111', + name: '#mesh', + is_hashtag: true, + on_radio: false, + last_read_at: null, + }, + { + key: '22222222222222222222222222222222', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, + }, + ]; + + it('prefers stable key lookup (case-insensitive)', () => { + const result = resolveChannelFromHashToken( + 'abcdef0123456789abcdef0123456789', + channels + ); + expect(result?.key).toBe('ABCDEF0123456789ABCDEF0123456789'); + }); + + it('supports legacy name-based hash lookup', () => { + const result = resolveChannelFromHashToken('Public', channels); + expect(result?.key).toBe('ABCDEF0123456789ABCDEF0123456789'); + }); + + it('supports legacy hashtag hash without leading #', () => { + const result = resolveChannelFromHashToken('mesh', channels); + expect(result?.key).toBe('11111111111111111111111111111111'); + }); +}); + +describe('resolveContactFromHashToken', () => { + const contacts: Contact[] = [ + { + public_key: 'abc123def4567890abc123def4567890abc123def4567890abc123def4567890', + name: 'Alice', + type: 1, + flags: 0, + last_path: null, + last_path_len: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + }, + { + public_key: 'def456abc1237890def456abc1237890def456abc1237890def456abc1237890', + name: 'Alice', + type: 1, + flags: 0, + last_path: null, + last_path_len: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + }, + { + public_key: 'eeeeee111111222222333333444444555555666666777777888888999999aaaa', + name: null, + type: 1, + flags: 0, + last_path: null, + last_path_len: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + }, + ]; + + it('prefers stable public-key lookup (case-insensitive)', () => { + const result = resolveContactFromHashToken( + 'ABC123DEF4567890ABC123DEF4567890ABC123DEF4567890ABC123DEF4567890', + contacts + ); + expect(result?.public_key).toBe( + 'abc123def4567890abc123def4567890abc123def4567890abc123def4567890' + ); + }); + + it('supports legacy display-name hash lookup', () => { + const result = resolveContactFromHashToken('Alice', contacts); + expect(result?.public_key).toBe( + 'abc123def4567890abc123def4567890abc123def4567890abc123def4567890' + ); + }); + + it('supports legacy unnamed-contact prefix hash lookup', () => { + const result = resolveContactFromHashToken('eeeeee111111', contacts); + expect(result?.public_key).toBe( + 'eeeeee111111222222333333444444555555666666777777888888999999aaaa' + ); + }); +}); + describe('getMapFocusHash', () => { it('generates hash with focus key', () => { const result = getMapFocusHash('ABCD1234'); diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index 0703b24..4657192 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -1,13 +1,18 @@ -import type { Conversation } from '../types'; +import type { Channel, Contact, Conversation } from '../types'; +import { getContactDisplayName } from './pubkey'; interface ParsedHashConversation { type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer'; + /** Conversation identity token (channel key or contact public key, or legacy name token) */ name: string; + /** Optional human-readable label segment (ignored for identity resolution) */ + label?: 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 or #map/focus/ABCD1234) +// Parse URL hash to get conversation +// (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>). export function parseHashConversation(): ParsedHashConversation | null { const hash = window.location.hash.slice(1); // Remove leading # if (!hash) return null; @@ -37,12 +42,51 @@ export function parseHashConversation(): ParsedHashConversation | null { if (slashIndex === -1) return null; const type = hash.slice(0, slashIndex); - const name = decodeURIComponent(hash.slice(slashIndex + 1)); - - if ((type === 'channel' || type === 'contact') && name) { - return { type, name }; + const value = hash.slice(slashIndex + 1); + if (!(type === 'channel' || type === 'contact') || !value) { + return null; } - return null; + + // Support both: + // - Legacy: #channel/Public + // - Stable: #channel/ + // - Stable + readable: #channel// + const valueSlashIndex = value.indexOf('/'); + const tokenRaw = valueSlashIndex === -1 ? value : value.slice(0, valueSlashIndex); + const labelRaw = valueSlashIndex === -1 ? '' : value.slice(valueSlashIndex + 1); + + const token = decodeURIComponent(tokenRaw); + if (!token) return null; + + return { + type, + name: token, + ...(labelRaw ? { label: decodeURIComponent(labelRaw) } : {}), + }; +} + +export function resolveChannelFromHashToken(token: string, channels: Channel[]): Channel | null { + const normalizedToken = token.trim(); + if (!normalizedToken) return null; + + // Preferred path: stable identity by channel key. + const byKey = channels.find((c) => c.key.toLowerCase() === normalizedToken.toLowerCase()); + if (byKey) return byKey; + + // Backward compatibility for legacy name-based hashes. + return channels.find((c) => c.name === normalizedToken || c.name === `#${normalizedToken}`) || null; +} + +export function resolveContactFromHashToken(token: string, contacts: Contact[]): Contact | null { + const normalizedToken = token.trim(); + if (!normalizedToken) return null; + + // Preferred path: stable identity by full public key. + const byKey = contacts.find((c) => c.public_key.toLowerCase() === normalizedToken.toLowerCase()); + if (byKey) return byKey; + + // Backward compatibility for legacy name/prefix-based hashes. + return contacts.find((c) => getContactDisplayName(c.name, c.public_key) === normalizedToken) || null; } /** @@ -59,10 +103,13 @@ export function getConversationHash(conv: Conversation | null): string { if (conv.type === 'raw') return '#raw'; if (conv.type === 'map') return '#map'; if (conv.type === 'visualizer') return '#visualizer'; - // Strip leading # from channel names for cleaner URLs - const name = - conv.type === 'channel' && conv.name.startsWith('#') ? conv.name.slice(1) : conv.name; - return `#${conv.type}/${encodeURIComponent(name)}`; + + // Use immutable IDs for identity, append readable label for UX. + if (conv.type === 'channel') { + const label = conv.name.startsWith('#') ? conv.name.slice(1) : conv.name; + return `#channel/${encodeURIComponent(conv.id)}/${encodeURIComponent(label)}`; + } + return `#contact/${encodeURIComponent(conv.id)}/${encodeURIComponent(conv.name)}`; } // Update URL hash without adding to history