mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-04 20:43:03 +02:00
Use stable referrent for same-name contacts
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/<id>
|
||||
// - Stable + readable: #channel/<id>/<display-name>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user