Oh god, so much code for such a minor flow. Ambiguous sender manually fetched prefix DMs are now visible.

This commit is contained in:
Jack Kingsman
2026-03-11 20:38:41 -07:00
parent e0df30b5f0
commit 20d0bd92bb
32 changed files with 876 additions and 65 deletions
+2
View File
@@ -192,6 +192,7 @@ export function App() {
mentions,
lastMessageTimes,
incrementUnread,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads,
@@ -214,6 +215,7 @@ export function App() {
addMessageIfNew,
trackNewMessage,
incrementUnread,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
+15 -2
View File
@@ -4,6 +4,7 @@ import { toast } from './ui/sonner';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
@@ -62,6 +63,13 @@ export function ChatHeader({
: null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const activeContact =
conversation.type === 'contact'
? contacts.find((contact) => contact.public_key === conversation.id)
: null;
const activeContactIsPrefixOnly = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
@@ -214,10 +222,15 @@ export function ChatHeader({
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
>
<Route className="h-4 w-4" aria-hidden="true" />
</button>
+27 -1
View File
@@ -2,6 +2,11 @@ import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import {
getContactDisplayName,
isPrefixOnlyContact,
isUnknownFullKeyContact,
} from '../utils/pubkey';
import {
isValidLocation,
calculateDistance,
@@ -133,6 +138,11 @@ export function ContactInfoPane({
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
const isUnknownFullKeyResolvedContact =
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -249,7 +259,7 @@ export function ContactInfoPane({
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">
{contact.name || contact.public_key.slice(0, 12)}
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
@@ -278,6 +288,22 @@ export function ContactInfoPane({
</div>
</div>
{isPrefixOnlyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM
arrives before we hear an advertisement. This contact stays read-only until the full
key resolves from a later advertisement.
</div>
)}
{isUnknownFullKeyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
We know this sender&apos;s full key, but we have not yet heard an advertisement that
fills in their identity details. Those details will appear automatically when an
advertisement arrives.
</div>
)}
{/* Info grid */}
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
+51 -10
View File
@@ -15,6 +15,7 @@ import type {
RadioConfig,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
const RepeaterDashboard = lazy(() =>
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
@@ -66,6 +67,25 @@ function LoadingPane({ label }: { label: string }) {
);
}
function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'prefix-only' }) {
if (variant === 'prefix-only') {
return (
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM arrives
before we learn their full identity. This conversation is read-only until we hear an
advertisement that resolves the full key.
</div>
);
}
return (
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
A full identity profile is not yet available because we have not heard an advertisement from
this sender. The contact will fill in automatically when an advertisement arrives.
</div>
);
}
export function ConversationPane({
activeConversation,
contacts,
@@ -106,6 +126,17 @@ export function ConversationPane({
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
const activeContact = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
}, [activeConversation, contacts]);
const isPrefixOnlyActiveContact = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const isUnknownFullKeyActiveContact =
activeContact !== null &&
!isPrefixOnlyActiveContact &&
isUnknownFullKeyContact(activeContact.public_key, activeContact.last_advert);
if (!activeConversation) {
return (
@@ -198,6 +229,12 @@ export function ConversationPane({
onOpenContactInfo={onOpenContactInfo}
onOpenChannelInfo={onOpenChannelInfo}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact && (
<ContactResolutionBanner variant="prefix-only" />
)}
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
<ContactResolutionBanner variant="unknown-full-key" />
)}
<MessageList
key={activeConversation.id}
messages={messages}
@@ -220,16 +257,20 @@ export function ConversationPane({
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected ? 'Radio not connected' : `Message ${activeConversation.name}...`
}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: `Message ${activeConversation.name}...`
}
/>
)}
</>
);
}
+32 -26
View File
@@ -169,34 +169,40 @@ export function NewMessageModal({
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.length === 0 ? (
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
) : (
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key)}
</div>
))
contacts
.filter((contact) => contact.public_key.length === 64)
.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</div>
))
)}
</div>
</TabsContent>
+1 -1
View File
@@ -416,7 +416,7 @@ export function Sidebar({
key: `${keyPrefix}-${contact.public_key}`,
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
unreadCount: getUnreadCount('contact', contact.public_key),
isMention: hasMention('contact', contact.public_key),
notificationsEnabled:
+1 -1
View File
@@ -58,7 +58,7 @@ export function useContactsAndChannels({
setActiveConversation({
type: 'contact',
id: created.public_key,
name: getContactDisplayName(created.name, created.public_key),
name: getContactDisplayName(created.name, created.public_key, created.last_advert),
});
},
[fetchAllContacts, setActiveConversation]
+2 -2
View File
@@ -151,7 +151,7 @@ export function useConversationRouter({
setActiveConversationState({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
hasSetDefaultConversation.current = true;
return;
@@ -179,7 +179,7 @@ export function useConversationRouter({
setActiveConversationState({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
hasSetDefaultConversation.current = true;
return;
+26
View File
@@ -11,6 +11,7 @@ import type { UseWebSocketOptions } from '../useWebSocket';
import { toast } from '../components/ui/sonner';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import { getContactDisplayName } from '../utils/pubkey';
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
import { getMessageContentKey } from './useConversationMessages';
import type {
@@ -40,6 +41,7 @@ interface UseRealtimeAppStateArgs {
addMessageIfNew: (msg: Message) => boolean;
trackNewMessage: (msg: Message) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
@@ -100,6 +102,7 @@ export function useRealtimeAppState({
addMessageIfNew,
trackNewMessage,
incrementUnread,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -225,6 +228,28 @@ export function useRealtimeAppState({
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
},
onContactResolved: (previousPublicKey: string, contact: Contact) => {
setContacts((prev) =>
mergeContactIntoList(
prev.filter((candidate) => candidate.public_key !== previousPublicKey),
contact
)
);
messageCache.rename(previousPublicKey, contact.public_key);
renameConversationState(
getStateKey('contact', previousPublicKey),
getStateKey('contact', contact.public_key)
);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === previousPublicKey) {
setActiveConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
}
},
onChannel: (channel: Channel) => {
mergeChannelIntoList(channel);
},
@@ -264,6 +289,7 @@ export function useRealtimeAppState({
fetchConfig,
hasNewerMessagesRef,
incrementUnread,
renameConversationState,
maxRawPackets,
mergeChannelIntoList,
pendingDeleteFallbackRef,
+25
View File
@@ -3,6 +3,7 @@ import { api } from '../api';
import {
getLastMessageTimes,
setLastMessageTime,
renameConversationTimeKey,
getStateKey,
type ConversationTimes,
} from '../utils/conversationState';
@@ -15,6 +16,7 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
markAllRead: () => void;
trackNewMessage: (msg: Message) => void;
refreshUnreads: () => Promise<void>;
@@ -170,6 +172,28 @@ export function useUnreadCounts(
}
}, []);
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
if (oldStateKey === newStateKey) return;
setUnreadCounts((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = (next[newStateKey] || 0) + next[oldStateKey];
delete next[oldStateKey];
return next;
});
setMentions((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = next[newStateKey] || next[oldStateKey];
delete next[oldStateKey];
return next;
});
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -204,6 +228,7 @@ export function useUnreadCounts(
mentions,
lastMessageTimes,
incrementUnread,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads: fetchUnreads,
+30
View File
@@ -138,6 +138,36 @@ export function remove(id: string): void {
cache.delete(id);
}
/** Move cached conversation state to a new conversation id. */
export function rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = cache.get(oldId);
if (!oldEntry) return;
const newEntry = cache.get(newId);
if (!newEntry) {
cache.delete(oldId);
cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
cache.delete(oldId);
cache.set(newId, {
messages: mergedMessages,
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
});
}
/** Clear the entire cache. */
export function clear(): void {
cache.clear();
@@ -196,4 +196,76 @@ describe('ConversationPane', () => {
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
it('shows a warning but keeps input for full-key contacts without an advert', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: '[unknown sender]',
},
contacts: [
{
public_key: 'cc'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
},
],
})}
/>
);
expect(screen.getByText(/A full identity profile is not yet available/i)).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
it('hides input and shows a read-only warning for prefix-only contacts', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'abc123def456',
name: 'abc123def456',
},
contacts: [
{
public_key: 'abc123def456',
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
},
],
})}
/>
);
expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
});
});
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
messageCache: {
addMessage: vi.fn(),
remove: vi.fn(),
rename: vi.fn(),
updateAck: vi.fn(),
},
}));
@@ -77,6 +78,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
addMessageIfNew: vi.fn(),
trackNewMessage: vi.fn(),
incrementUnread: vi.fn(),
renameConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
@@ -193,6 +195,58 @@ describe('useRealtimeAppState', () => {
expect(pendingDeleteFallbackRef.current).toBe(true);
});
it('resolves a prefix-only contact into a full key and updates active conversation state', () => {
const previousPublicKey = 'abc123def456';
const resolvedContact: Contact = {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
};
const activeConversationRef = {
current: {
type: 'contact',
id: previousPublicKey,
name: 'abc123def456',
} satisfies Conversation,
};
const { args, fns } = createRealtimeArgs({
activeConversationRef,
});
const { result } = renderHook(() => useRealtimeAppState(args));
act(() => {
result.current.onContactResolved?.(previousPublicKey, resolvedContact);
});
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
expect(mocks.messageCache.rename).toHaveBeenCalledWith(
previousPublicKey,
resolvedContact.public_key
);
expect(args.renameConversationState).toHaveBeenCalledWith(
`contact-${previousPublicKey}`,
`contact-${resolvedContact.public_key}`
);
expect(args.setActiveConversation).toHaveBeenCalledWith({
type: 'contact',
id: resolvedContact.public_key,
name: '[unknown sender]',
});
});
it('appends raw packets using observation identity dedup', () => {
const { args, fns } = createRealtimeArgs();
const packet: RawPacket = {
@@ -103,6 +103,39 @@ describe('useWebSocket dispatch', () => {
expect(onContact.mock.calls[0][0]).toHaveProperty('name');
});
it('routes contact_resolved event to onContactResolved', () => {
const onContactResolved = vi.fn();
renderHook(() => useWebSocket({ onContactResolved }));
const contact = {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
fireMessage({
type: 'contact_resolved',
data: {
previous_public_key: 'abc123def456',
contact,
},
});
expect(onContactResolved).toHaveBeenCalledOnce();
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
});
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));
+52
View File
@@ -14,6 +14,58 @@ describe('wsEvents', () => {
});
});
it('parses contact_resolved events', () => {
const event = parseWsEvent(
JSON.stringify({
type: 'contact_resolved',
data: {
previous_public_key: 'abc123def456',
contact: {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
},
})
);
expect(event).toEqual({
type: 'contact_resolved',
data: {
previous_public_key: 'abc123def456',
contact: {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
},
});
});
it('parses channel_deleted events', () => {
const event = parseWsEvent(JSON.stringify({ type: 'channel_deleted', data: { key: 'bb' } }));
+9
View File
@@ -16,6 +16,7 @@ export interface UseWebSocketOptions {
onHealth?: (health: HealthStatus) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onContactResolved?: (previousPublicKey: string, contact: Contact) => void;
onContactDeleted?: (publicKey: string) => void;
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
@@ -102,6 +103,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
case 'contact':
handlers.onContact?.(msg.data as Contact);
break;
case 'contact_resolved': {
const resolved = msg.data as {
previous_public_key: string;
contact: Contact;
};
handlers.onContactResolved?.(resolved.previous_public_key, resolved.contact);
break;
}
case 'channel':
handlers.onChannel?.(msg.data as Channel);
break;
+16
View File
@@ -41,6 +41,22 @@ export function setLastMessageTime(key: string, timestamp: number): Conversation
return { ...lastMessageTimesCache };
}
/**
* Move conversation timing state to a new key, preserving the most recent timestamp.
*/
export function renameConversationTimeKey(oldKey: string, newKey: string): ConversationTimes {
if (oldKey === newKey) return { ...lastMessageTimesCache };
const oldTimestamp = lastMessageTimesCache[oldKey];
const newTimestamp = lastMessageTimesCache[newKey];
if (oldTimestamp !== undefined) {
lastMessageTimesCache[newKey] =
newTimestamp === undefined ? oldTimestamp : Math.max(newTimestamp, oldTimestamp);
delete lastMessageTimesCache[oldKey];
}
return { ...lastMessageTimesCache };
}
/**
* Generate a state tracking key for message times.
*
+16 -2
View File
@@ -21,6 +21,20 @@ function getPubkeyPrefix(key: string): string {
/**
* Get a display name for a contact, falling back to pubkey prefix.
*/
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
return name || getPubkeyPrefix(pubkey);
export function getContactDisplayName(
name: string | null | undefined,
pubkey: string,
lastAdvert?: number | null
): string {
if (name) return name;
if (isUnknownFullKeyContact(pubkey, lastAdvert)) return '[unknown sender]';
return getPubkeyPrefix(pubkey);
}
export function isPrefixOnlyContact(pubkey: string): boolean {
return pubkey.length < 64;
}
export function isUnknownFullKeyContact(pubkey: string, lastAdvert?: number | null): boolean {
return pubkey.length === 64 && !lastAdvert;
}
+7 -2
View File
@@ -86,14 +86,19 @@ export function resolveChannelFromHashToken(token: string, channels: Channel[]):
export function resolveContactFromHashToken(token: string, contacts: Contact[]): Contact | null {
const normalizedToken = token.trim();
if (!normalizedToken) return null;
const lowerToken = normalizedToken.toLowerCase();
// Preferred path: stable identity by full public key.
const byKey = contacts.find((c) => c.public_key.toLowerCase() === normalizedToken.toLowerCase());
const byKey = contacts.find((c) => c.public_key.toLowerCase() === lowerToken);
if (byKey) return byKey;
// Backward compatibility for legacy name/prefix-based hashes.
return (
contacts.find((c) => getContactDisplayName(c.name, c.public_key) === normalizedToken) || null
contacts.find(
(c) =>
getContactDisplayName(c.name, c.public_key, c.last_advert) === normalizedToken ||
c.public_key.toLowerCase().startsWith(lowerToken)
) || null
);
}
+7
View File
@@ -10,6 +10,11 @@ export interface ContactDeletedPayload {
public_key: string;
}
export interface ContactResolvedPayload {
previous_public_key: string;
contact: Contact;
}
export interface ChannelDeletedPayload {
key: string;
}
@@ -23,6 +28,7 @@ export type KnownWsEvent =
| { type: 'health'; data: HealthStatus }
| { type: 'message'; data: Message }
| { type: 'contact'; data: Contact }
| { type: 'contact_resolved'; data: ContactResolvedPayload }
| { type: 'channel'; data: Channel }
| { type: 'contact_deleted'; data: ContactDeletedPayload }
| { type: 'channel_deleted'; data: ChannelDeletedPayload }
@@ -55,6 +61,7 @@ export function parseWsEvent(raw: string): ParsedWsEvent {
case 'health':
case 'message':
case 'contact':
case 'contact_resolved':
case 'channel':
case 'contact_deleted':
case 'channel_deleted':