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
+3 -8
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
+3 -4
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
+2 -3
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)
+3 -4
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;
+3 -3
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}`);
});
});
+2 -9
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}`);
});
});
+16 -30
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)
+2 -11
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}`;
}
/**
+1 -10
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);
}
/**