Begone, prefix matching; use the whole key you have

This commit is contained in:
Jack Kingsman
2026-01-19 00:01:25 -08:00
parent 9e86d263f7
commit 3cb5711b5c
13 changed files with 76 additions and 123 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

View File

@@ -13,7 +13,7 @@
<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-eCIKhXih.js"></script>
<script type="module" crossorigin src="/assets/index-Bt8bmX1W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CnRBRJ10.css">
</head>
<body>

View File

@@ -26,7 +26,7 @@ import {
clearLocalStorageConversationState,
} from './utils/conversationState';
import { formatTime } from './utils/messageParser';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { getContactDisplayName } from './utils/pubkey';
import { parseHashConversation, updateUrlHash, getMapFocusHash } from './utils/urlHash';
import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils';
import {
@@ -167,7 +167,7 @@ export function App() {
return msg.conversation_key === activeConv.id;
}
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
return msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
return msg.conversation_key === activeConv.id;
}
return false;
})();
@@ -519,12 +519,7 @@ export function App() {
// Compute optimistic new state
const wasFavorited = isFavorite(favorites, type, id);
const optimisticFavorites = wasFavorited
? favorites.filter((f) => {
if (f.type !== type) return true;
// Use prefix matching for contacts, exact match for channels
if (type === 'contact') return !pubkeysMatch(f.id, id);
return f.id !== id;
})
? favorites.filter((f) => !(f.type === type && f.id === id))
: [...favorites, { type, id }];
// Optimistic update

View File

@@ -5,11 +5,10 @@ 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 */
/** Public key of contact to focus on and open popup */
focusedKey?: string | null;
}
@@ -102,10 +101,10 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
);
}, [contacts]);
// Find the focused contact by key prefix
// Find the focused contact by key
const focusedContact = useMemo(() => {
if (!focusedKey) return null;
return mappableContacts.find((c) => pubkeysMatch(c.public_key, focusedKey)) || null;
return mappableContacts.find((c) => c.public_key === focusedKey) || null;
}, [focusedKey, mappableContacts]);
// Track marker refs to open popup programmatically

View File

@@ -2,7 +2,6 @@ import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNo
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
@@ -201,10 +200,10 @@ export function MessageList({
}
}, []);
// Look up contact by public key or prefix
// Look up contact by public key
const getContact = (conversationKey: string | null): Contact | null => {
if (!conversationKey) return null;
return contacts.find((c) => pubkeysMatch(c.public_key, conversationKey)) || null;
return contacts.find((c) => c.public_key === conversationKey) || null;
};
// Look up contact by name (for channel messages where we parse sender from text)

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import type { Contact, Channel, Conversation, Favorite } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
import { getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import { isFavorite } from '../utils/favorites';
@@ -96,7 +96,7 @@ export function Sidebar({
return acc;
}, []);
// Deduplicate contacts by 12-char prefix, preferring ones with names
// Deduplicate contacts by public key, preferring ones with names
// Also filter out any contacts with empty public keys
const uniqueContacts = contacts
.filter((c) => c.public_key && c.public_key.length > 0)
@@ -107,8 +107,7 @@ export function Sidebar({
return (a.name || '').localeCompare(b.name || '');
})
.reduce<Contact[]>((acc, contact) => {
const prefix = getPubkeyPrefix(contact.public_key);
if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) {
if (!acc.some((c) => c.public_key === contact.public_key)) {
acc.push(contact);
}
return acc;

View File

@@ -349,12 +349,12 @@ describe('Integration: State Key Contract', () => {
expect(stateKey).toBe(`channel-${channelKey}`);
});
it('generates correct contact state key with prefix', () => {
it('generates correct contact state key with full public key', () => {
const publicKey = fixtures.advertisement_with_gps.expected_ws_event.data.public_key;
const stateKey = getStateKey('contact', publicKey);
// Contact state key uses 12-char prefix
expect(stateKey).toBe(`contact-${publicKey.substring(0, 12)}`);
// Contact state key uses full public key
expect(stateKey).toBe(`contact-${publicKey}`);
});
});

View File

@@ -104,17 +104,10 @@ describe('getStateKey', () => {
expect(key).toBe('channel-5');
});
it('creates contact state key with 12-char prefix', () => {
it('creates contact state key with full public key', () => {
const fullKey = 'abcdef123456789012345678901234567890';
const key = getStateKey('contact', fullKey);
expect(key).toBe('contact-abcdef123456');
});
it('handles contact key shorter than 12 chars', () => {
const shortKey = 'abc123';
const key = getStateKey('contact', shortKey);
expect(key).toBe('contact-abc123');
expect(key).toBe(`contact-${fullKey}`);
});
});

View File

@@ -7,7 +7,6 @@
import { describe, it, expect } from 'vitest';
import type { Message, Conversation } from '../types';
import { getPubkeyPrefix, pubkeysMatch } from '../utils/pubkey';
/**
* Determine if a message should increment unread count.
@@ -32,13 +31,9 @@ function shouldIncrementUnread(
}
if (msg.type === 'PRIV' && msg.conversation_key) {
// Use 12-char prefix for contact key
const key = `contact-${getPubkeyPrefix(msg.conversation_key)}`;
// Don't count if this contact is active (compare by prefix)
if (
activeConversation?.type === 'contact' &&
pubkeysMatch(activeConversation.id, msg.conversation_key)
) {
const key = `contact-${msg.conversation_key}`;
// Don't count if this contact is active
if (activeConversation?.type === 'contact' && activeConversation.id === msg.conversation_key) {
return null;
}
return { key };
@@ -56,12 +51,7 @@ function getUnreadCount(
id: string,
unreadCounts: Record<string, number>
): number {
if (type === 'channel') {
return unreadCounts[`channel-${id}`] || 0;
}
// For contacts, use prefix
const prefix = `contact-${getPubkeyPrefix(id)}`;
return unreadCounts[prefix] || 0;
return unreadCounts[`${type}-${id}`] || 0;
}
describe('shouldIncrementUnread', () => {
@@ -137,17 +127,21 @@ describe('shouldIncrementUnread', () => {
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'contact-abc123456789' });
// State key uses full public key
expect(result).toEqual({
key: 'contact-abc123456789012345678901234567890123456789012345678901234567',
});
});
it('returns null for incoming direct message when viewing that contact', () => {
const fullKey = 'abc123456789012345678901234567890123456789012345678901234567';
const msg = createMessage({
type: 'PRIV',
conversation_key: 'abc123456789012345678901234567890123456789012345678901234567',
conversation_key: fullKey,
});
const activeConversation: Conversation = {
type: 'contact',
id: 'abc123456789fullkey12345678901234567890123456789012345678',
id: fullKey, // Same full key - exact match required
name: 'Alice',
};
@@ -193,23 +187,15 @@ describe('getUnreadCount', () => {
expect(getUnreadCount('channel', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9', counts)).toBe(0);
});
it('returns count for contact using 12-char prefix', () => {
const counts = { 'contact-abc123456789': 5 };
it('returns count for contact using full public key', () => {
const fullKey = 'abc123456789fullpublickey123456789012345678901234';
const counts = { [`contact-${fullKey}`]: 5 };
// Full public key lookup should match the prefix
expect(
getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)
).toBe(5);
});
it('handles contact key shorter than 12 chars', () => {
const counts = { 'contact-short': 2 };
expect(getUnreadCount('contact', 'short', counts)).toBe(2);
expect(getUnreadCount('contact', fullKey, counts)).toBe(5);
});
it('returns 0 for contact with no unread', () => {
const counts = { 'contact-abc123456789': 5 };
const counts = { 'contact-abc123456789fullpublickey123456789012345678901234': 5 };
expect(
getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)

View File

@@ -9,8 +9,6 @@
* across devices - see useUnreadCounts hook.
*/
import { getPubkeyPrefix } from './pubkey';
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
@@ -49,17 +47,10 @@ export function setLastMessageTime(key: string, timestamp: number): Conversation
* This is NOT the same as Message.conversation_key (the database field).
* This creates prefixed keys for state tracking:
* - Channels: "channel-{channelKey}"
* - Contacts: "contact-{12-char-pubkey-prefix}"
*
* The 12-char prefix for contacts ensures consistent matching regardless
* of whether we have a full 64-char pubkey or just a prefix.
* - Contacts: "contact-{publicKey}"
*/
export function getStateKey(type: 'channel' | 'contact', id: string): string {
if (type === 'channel') {
return `channel-${id}`;
}
// For contacts, use 12-char prefix for consistent matching
return `contact-${getPubkeyPrefix(id)}`;
return `${type}-${id}`;
}
/**

View File

@@ -7,27 +7,18 @@
*/
import type { Favorite } from '../types';
import { pubkeysMatch } from './pubkey';
const FAVORITES_KEY = 'remoteterm-favorites';
/**
* Check if a conversation is favorited (from provided favorites array)
*
* For contacts, uses prefix matching to handle full pubkeys vs 12-char prefixes.
*/
export function isFavorite(
favorites: Favorite[],
type: 'channel' | 'contact',
id: string
): boolean {
return favorites.some((f) => {
if (f.type !== type) return false;
// For contacts, use prefix matching (handles full keys vs prefixes)
if (type === 'contact') return pubkeysMatch(f.id, id);
// For channels, exact match
return f.id === id;
});
return favorites.some((f) => f.type === type && f.id === id);
}
/**