From 20d0bd92bb6b9b67ae75bd5e78997c2e3c7e530b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 11 Mar 2026 20:38:41 -0700 Subject: [PATCH] Oh god, so much code for such a minor flow. Ambiguous sender manually fetched prefix DMs are now visible. --- app/event_handlers.py | 28 +++ app/events.py | 8 + app/packet_processor.py | 17 +- app/radio_sync.py | 14 ++ app/repository/contacts.py | 104 ++++++++++- app/repository/messages.py | 3 +- app/routers/contacts.py | 40 ++++- app/routers/messages.py | 5 + app/services/contact_reconciliation.py | 20 ++- frontend/src/App.tsx | 2 + frontend/src/components/ChatHeader.tsx | 17 +- frontend/src/components/ContactInfoPane.tsx | 28 ++- frontend/src/components/ConversationPane.tsx | 61 +++++-- frontend/src/components/NewMessageModal.tsx | 58 +++--- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/hooks/useContactsAndChannels.ts | 2 +- frontend/src/hooks/useConversationRouter.ts | 4 +- frontend/src/hooks/useRealtimeAppState.ts | 26 +++ frontend/src/hooks/useUnreadCounts.ts | 25 +++ frontend/src/messageCache.ts | 30 ++++ frontend/src/test/conversationPane.test.tsx | 72 ++++++++ frontend/src/test/useRealtimeAppState.test.ts | 54 ++++++ .../src/test/useWebSocket.dispatch.test.ts | 33 ++++ frontend/src/test/wsEvents.test.ts | 52 ++++++ frontend/src/useWebSocket.ts | 9 + frontend/src/utils/conversationState.ts | 16 ++ frontend/src/utils/pubkey.ts | 18 +- frontend/src/utils/urlHash.ts | 9 +- frontend/src/wsEvents.ts | 7 + tests/e2e/specs/bot.spec.ts | 2 +- tests/e2e/specs/messaging.spec.ts | 6 +- tests/test_event_handlers.py | 169 +++++++++++++++++- 32 files changed, 876 insertions(+), 65 deletions(-) diff --git a/app/event_handlers.py b/app/event_handlers.py index d9361fe..83deb53 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -13,6 +13,7 @@ from app.repository import ( from app.services import dm_ack_tracker from app.services.contact_reconciliation import ( claim_prefix_messages_for_contact, + promote_prefix_contacts_for_contact, record_contact_name_and_reconcile, ) from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast @@ -88,6 +89,20 @@ async def on_contact_message(event: "Event") -> None: sender_pubkey[:12], ) return + elif sender_pubkey: + placeholder_upsert = ContactUpsert( + public_key=sender_pubkey.lower(), + type=0, + last_seen=received_at, + last_contacted=received_at, + first_seen=received_at, + on_radio=False, + out_path_hash_mode=-1, + ) + await ContactRepository.upsert(placeholder_upsert) + contact = await ContactRepository.get_by_key(sender_pubkey.lower()) + if contact: + broadcast_event("contact", contact.model_dump()) # Try to create message - INSERT OR IGNORE handles duplicates atomically # If the packet processor already stored this message, this returns None @@ -231,6 +246,10 @@ async def on_new_contact(event: "Event") -> None: contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True) contact_upsert.last_seen = int(time.time()) await ContactRepository.upsert(contact_upsert) + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=public_key, + log=logger, + ) adv_name = payload.get("adv_name") await record_contact_name_and_reconcile( @@ -251,6 +270,15 @@ async def on_new_contact(event: "Event") -> None: else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump() ), ) + if db_contact: + for old_key in promoted_keys: + broadcast_event( + "contact_resolved", + { + "previous_public_key": old_key, + "contact": db_contact.model_dump(), + }, + ) async def on_ack(event: "Event") -> None: diff --git a/app/events.py b/app/events.py index b396786..51c6ecb 100644 --- a/app/events.py +++ b/app/events.py @@ -16,6 +16,7 @@ WsEventType = Literal[ "health", "message", "contact", + "contact_resolved", "channel", "contact_deleted", "channel_deleted", @@ -30,6 +31,11 @@ class ContactDeletedPayload(TypedDict): public_key: str +class ContactResolvedPayload(TypedDict): + previous_public_key: str + contact: Contact + + class ChannelDeletedPayload(TypedDict): key: str @@ -49,6 +55,7 @@ WsEventPayload = ( HealthResponse | Message | Contact + | ContactResolvedPayload | Channel | ContactDeletedPayload | ChannelDeletedPayload @@ -61,6 +68,7 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = { "health": TypeAdapter(HealthResponse), "message": TypeAdapter(Message), "contact": TypeAdapter(Contact), + "contact_resolved": TypeAdapter(ContactResolvedPayload), "channel": TypeAdapter(Channel), "contact_deleted": TypeAdapter(ContactDeletedPayload), "channel_deleted": TypeAdapter(ChannelDeletedPayload), diff --git a/app/packet_processor.py b/app/packet_processor.py index a5c1bb2..8a11310 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -40,7 +40,10 @@ from app.repository import ( ContactRepository, RawPacketRepository, ) -from app.services.contact_reconciliation import record_contact_name_and_reconcile +from app.services.contact_reconciliation import ( + promote_prefix_contacts_for_contact, + record_contact_name_and_reconcile, +) from app.services.messages import ( create_dm_message_from_decrypted as _create_dm_message_from_decrypted, ) @@ -504,6 +507,10 @@ async def _process_advertisement( ) await ContactRepository.upsert(contact_upsert) + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=advert.public_key, + log=logger, + ) await record_contact_name_and_reconcile( public_key=advert.public_key, contact_name=advert.name, @@ -516,6 +523,14 @@ async def _process_advertisement( db_contact = await ContactRepository.get_by_key(advert.public_key.lower()) if db_contact: broadcast_event("contact", db_contact.model_dump()) + for old_key in promoted_keys: + broadcast_event( + "contact_resolved", + { + "previous_public_key": old_key, + "contact": db_contact.model_dump(), + }, + ) else: broadcast_event( "contact", diff --git a/app/radio_sync.py b/app/radio_sync.py index 8861b12..1b77c10 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -732,6 +732,12 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict: continue if not contact: continue + if len(contact.public_key) < 64: + logger.debug( + "Skipping unresolved prefix-only favorite contact '%s' for radio sync", + favorite.id, + ) + continue key = contact.public_key.lower() if key in selected_keys: continue @@ -801,6 +807,9 @@ async def ensure_contact_on_radio( if not contact: logger.debug("Cannot sync favorite contact %s: not found", public_key[:12]) return {"loaded": 0, "error": "Contact not found"} + if len(contact.public_key) < 64: + logger.debug("Cannot sync unresolved prefix-only contact %s to radio", public_key) + return {"loaded": 0, "error": "Full contact key not yet known"} if mc is not None: _last_contact_sync = now @@ -833,6 +842,11 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict failed = 0 for contact in contacts: + if len(contact.public_key) < 64: + logger.debug( + "Skipping unresolved prefix-only contact %s during radio load", contact.public_key + ) + continue radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12]) if radio_contact: already_on_radio += 1 diff --git a/app/repository/contacts.py b/app/repository/contacts.py index 0d5f313..b5e3ece 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -168,6 +168,9 @@ class ContactRepository: the prefix (to avoid silently selecting the wrong contact). """ normalized_prefix = prefix.lower() + exact = await ContactRepository.get_by_key(normalized_prefix) + if exact: + return exact cursor = await db.conn.execute( "SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2", (f"{normalized_prefix}%",), @@ -258,7 +261,7 @@ class ContactRepository: cursor = await db.conn.execute( """ SELECT * FROM contacts - WHERE type != 2 AND last_contacted IS NOT NULL + WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64 ORDER BY last_contacted DESC LIMIT ? """, @@ -273,7 +276,7 @@ class ContactRepository: cursor = await db.conn.execute( """ SELECT * FROM contacts - WHERE type != 2 AND last_advert IS NOT NULL + WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64 ORDER BY last_advert DESC LIMIT ? """, @@ -406,6 +409,103 @@ class ContactRepository: await db.conn.commit() return cursor.rowcount > 0 + @staticmethod + async def promote_prefix_placeholders(full_key: str) -> list[str]: + """Promote prefix-only placeholder contacts to a resolved full key. + + Returns the placeholder public keys that were merged into the full key. + """ + normalized_full_key = full_key.lower() + cursor = await db.conn.execute( + """ + SELECT public_key, last_seen, last_contacted, first_seen, last_read_at + FROM contacts + WHERE length(public_key) < 64 + AND ? LIKE public_key || '%' + ORDER BY length(public_key) DESC, public_key + """, + (normalized_full_key,), + ) + rows = list(await cursor.fetchall()) + if not rows: + return [] + + promoted_keys: list[str] = [] + full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None + + for row in rows: + old_key = row["public_key"] + if old_key == normalized_full_key: + continue + + match_cursor = await db.conn.execute( + """ + SELECT COUNT(*) AS match_count + FROM contacts + WHERE length(public_key) = 64 + AND public_key LIKE ? || '%' + """, + (old_key,), + ) + match_row = await match_cursor.fetchone() + if (match_row["match_count"] if match_row is not None else 0) != 1: + continue + + if full_exists: + await db.conn.execute( + """ + UPDATE contacts + SET last_seen = CASE + WHEN contacts.last_seen IS NULL THEN ? + WHEN ? IS NULL THEN contacts.last_seen + WHEN ? > contacts.last_seen THEN ? + ELSE contacts.last_seen + END, + last_contacted = CASE + WHEN contacts.last_contacted IS NULL THEN ? + WHEN ? IS NULL THEN contacts.last_contacted + WHEN ? > contacts.last_contacted THEN ? + ELSE contacts.last_contacted + END, + first_seen = CASE + WHEN contacts.first_seen IS NULL THEN ? + WHEN ? IS NULL THEN contacts.first_seen + WHEN ? < contacts.first_seen THEN ? + ELSE contacts.first_seen + END, + last_read_at = COALESCE(contacts.last_read_at, ?) + WHERE public_key = ? + """, + ( + row["last_seen"], + row["last_seen"], + row["last_seen"], + row["last_seen"], + row["last_contacted"], + row["last_contacted"], + row["last_contacted"], + row["last_contacted"], + row["first_seen"], + row["first_seen"], + row["first_seen"], + row["first_seen"], + row["last_read_at"], + normalized_full_key, + ), + ) + await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,)) + else: + await db.conn.execute( + "UPDATE contacts SET public_key = ? WHERE public_key = ?", + (normalized_full_key, old_key), + ) + full_exists = True + + promoted_keys.append(old_key) + + await db.conn.commit() + return promoted_keys + @staticmethod async def mark_all_read(timestamp: int) -> None: """Mark all contacts as read at the given timestamp.""" diff --git a/app/repository/messages.py b/app/repository/messages.py index 5ed0f15..3c1f926 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -162,7 +162,8 @@ class MessageRepository: AND ? LIKE conversation_key || '%' AND ( SELECT COUNT(*) FROM contacts - WHERE public_key LIKE messages.conversation_key || '%' + WHERE length(public_key) = 64 + AND public_key LIKE messages.conversation_key || '%' ) = 1""", (lower_key, lower_key), ) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 124c698..c75b235 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -28,7 +28,10 @@ from app.repository import ( ContactRepository, MessageRepository, ) -from app.services.contact_reconciliation import reconcile_contact_messages +from app.services.contact_reconciliation import ( + promote_prefix_contacts_for_contact, + reconcile_contact_messages, +) from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) @@ -93,6 +96,19 @@ async def _broadcast_contact_update(contact: Contact) -> None: broadcast_event("contact", contact.model_dump()) +async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None: + from app.websocket import broadcast_event + + for old_key in previous_public_keys: + broadcast_event( + "contact_resolved", + { + "previous_public_key": old_key, + "contact": contact.model_dump(), + }, + ) + + async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics: name_history = await ContactNameHistoryRepository.get_history(contact.public_key) dm_count = await MessageRepository.count_dm_messages(contact.public_key) @@ -257,6 +273,16 @@ async def create_contact( if refreshed is not None: existing = refreshed + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=request.public_key, + log=logger, + ) + if promoted_keys: + refreshed = await ContactRepository.get_by_key(request.public_key) + if refreshed is not None: + existing = refreshed + await _broadcast_contact_resolution(promoted_keys, existing) + # Trigger historical decryption if requested (even for existing contacts) if request.try_historical: await start_historical_dm_decryption( @@ -275,6 +301,10 @@ async def create_contact( ) await ContactRepository.upsert(contact_upsert) logger.info("Created contact %s", lower_key[:12]) + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=lower_key, + log=logger, + ) await reconcile_contact_messages( public_key=lower_key, @@ -289,6 +319,7 @@ async def create_contact( stored = await ContactRepository.get_by_key(lower_key) if stored is None: raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded") + await _broadcast_contact_resolution(promoted_keys, stored) return stored @@ -368,12 +399,19 @@ async def sync_contacts_from_radio() -> dict: await ContactRepository.upsert( ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True) ) + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=lower_key, + log=logger, + ) synced_keys.append(lower_key) await reconcile_contact_messages( public_key=lower_key, contact_name=contact_data.get("adv_name"), log=logger, ) + stored = await ContactRepository.get_by_key(lower_key) + if stored is not None: + await _broadcast_contact_resolution(promoted_keys, stored) count += 1 # Clear on_radio for contacts not found on the radio diff --git a/app/routers/messages.py b/app/routers/messages.py index 11ea1cf..4e14d16 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -108,6 +108,11 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message: raise HTTPException( status_code=404, detail=f"Contact not found in database: {request.destination}" ) + if len(db_contact.public_key) < 64: + raise HTTPException( + status_code=409, + detail="Cannot send to an unresolved prefix-only contact until a full key is known", + ) return await send_direct_message_to_contact( contact=db_contact, diff --git a/app/services/contact_reconciliation.py b/app/services/contact_reconciliation.py index 7b71dc4..e388503 100644 --- a/app/services/contact_reconciliation.py +++ b/app/services/contact_reconciliation.py @@ -2,11 +2,29 @@ import logging -from app.repository import ContactNameHistoryRepository, MessageRepository +from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository logger = logging.getLogger(__name__) +async def promote_prefix_contacts_for_contact( + *, + public_key: str, + contact_repository=ContactRepository, + log: logging.Logger | None = None, +) -> list[str]: + """Promote prefix-only placeholder contacts once a full key is known.""" + normalized_key = public_key.lower() + promoted = await contact_repository.promote_prefix_placeholders(normalized_key) + if promoted: + (log or logger).info( + "Promoted %d prefix contact placeholder(s) for %s", + len(promoted), + normalized_key[:12], + ) + return promoted + + async def claim_prefix_messages_for_contact( *, public_key: str, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9981ce7..5c2f474 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index cda4d81..ecfac33 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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({
{conversation.type === 'contact' && ( diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 274feb0..09e6bda 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -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 ( !open && onClose()}> @@ -249,7 +259,7 @@ export function ContactInfoPane({ />

- {contact.name || contact.public_key.slice(0, 12)} + {getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}

+ {isPrefixOnlyResolvedContact && ( +
+ 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. +
+ )} + + {isUnknownFullKeyResolvedContact && ( +
+ We know this sender'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. +
+ )} + {/* Info grid */}
diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 69dfafc..ca0af70 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -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 ( +
+ 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. +
+ ); + } + + return ( +
+ 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. +
+ ); +} + 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 && ( + + )} + {activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && ( + + )} - + {activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : ( + + )} ); } diff --git a/frontend/src/components/NewMessageModal.tsx b/frontend/src/components/NewMessageModal.tsx index 246764a..9b3ba3b 100644 --- a/frontend/src/components/NewMessageModal.tsx +++ b/frontend/src/components/NewMessageModal.tsx @@ -169,34 +169,40 @@ export function NewMessageModal({
- {contacts.length === 0 ? ( + {contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
No contacts available
) : ( - contacts.map((contact) => ( -
{ - 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)} -
- )) + contacts + .filter((contact) => contact.public_key.length === 64) + .map((contact) => ( +
{ + 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)} +
+ )) )}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f0c26c5..826ae2b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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: diff --git a/frontend/src/hooks/useContactsAndChannels.ts b/frontend/src/hooks/useContactsAndChannels.ts index ac84f66..5933af3 100644 --- a/frontend/src/hooks/useContactsAndChannels.ts +++ b/frontend/src/hooks/useContactsAndChannels.ts @@ -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] diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index 2bd2500..3605ac6 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -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; diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index a4a6df1..fb5b9cc 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -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; 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, diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 9a043c7..94e4259 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -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; lastMessageTimes: ConversationTimes; incrementUnread: (stateKey: string, hasMention?: boolean) => void; + renameConversationState: (oldStateKey: string, newStateKey: string) => void; markAllRead: () => void; trackNewMessage: (msg: Message) => void; refreshUnreads: () => Promise; @@ -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, diff --git a/frontend/src/messageCache.ts b/frontend/src/messageCache.ts index 81ebf9c..be380f6 100644 --- a/frontend/src/messageCache.ts +++ b/frontend/src/messageCache.ts @@ -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(); diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 5e62899..86f1115 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -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( + + ); + + 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( + + ); + + expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument(); + expect(screen.queryByTestId('message-input')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts index f723f08..2531eaa 100644 --- a/frontend/src/test/useRealtimeAppState.test.ts +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -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 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 = { diff --git a/frontend/src/test/useWebSocket.dispatch.test.ts b/frontend/src/test/useWebSocket.dispatch.test.ts index d381b1d..99d3b8c 100644 --- a/frontend/src/test/useWebSocket.dispatch.test.ts +++ b/frontend/src/test/useWebSocket.dispatch.test.ts @@ -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 })); diff --git a/frontend/src/test/wsEvents.test.ts b/frontend/src/test/wsEvents.test.ts index 4ff03be..8faef36 100644 --- a/frontend/src/test/wsEvents.test.ts +++ b/frontend/src/test/wsEvents.test.ts @@ -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' } })); diff --git a/frontend/src/useWebSocket.ts b/frontend/src/useWebSocket.ts index f97294e..c769d6d 100644 --- a/frontend/src/useWebSocket.ts +++ b/frontend/src/useWebSocket.ts @@ -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; diff --git a/frontend/src/utils/conversationState.ts b/frontend/src/utils/conversationState.ts index b4ce2c9..d85860b 100644 --- a/frontend/src/utils/conversationState.ts +++ b/frontend/src/utils/conversationState.ts @@ -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. * diff --git a/frontend/src/utils/pubkey.ts b/frontend/src/utils/pubkey.ts index 875ff27..eae71d7 100644 --- a/frontend/src/utils/pubkey.ts +++ b/frontend/src/utils/pubkey.ts @@ -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; } diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index 6cd9bb4..82b07cf 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -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 ); } diff --git a/frontend/src/wsEvents.ts b/frontend/src/wsEvents.ts index bd0719d..7a1796a 100644 --- a/frontend/src/wsEvents.ts +++ b/frontend/src/wsEvents.ts @@ -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': diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts index 8b38e32..77090df 100644 --- a/tests/e2e/specs/bot.spec.ts +++ b/tests/e2e/specs/bot.spec.ts @@ -59,7 +59,7 @@ test.describe('Bot functionality', () => { const triggerMessage = `!e2etest ${Date.now()}`; const input = page.getByPlaceholder(/type a message|message #flightless/i); await input.fill(triggerMessage); - await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); // --- Step 4: Verify bot response appears --- // Bot has ~2s delay before responding, plus radio send time diff --git a/tests/e2e/specs/messaging.spec.ts b/tests/e2e/specs/messaging.spec.ts index b81d5ec..1267603 100644 --- a/tests/e2e/specs/messaging.spec.ts +++ b/tests/e2e/specs/messaging.spec.ts @@ -21,7 +21,7 @@ test.describe('Channel messaging in #flightless', () => { await input.fill(testMessage); // Send it - await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); // Verify message appears in the message list await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 }); @@ -35,7 +35,7 @@ test.describe('Channel messaging in #flightless', () => { const testMessage = `ack-test-${Date.now()}`; const input = page.getByPlaceholder(/type a message|message #flightless/i); await input.fill(testMessage); - await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); // Wait for the message to appear const messageEl = page.getByText(testMessage); @@ -56,7 +56,7 @@ test.describe('Channel messaging in #flightless', () => { const testMessage = `resend-test-${Date.now()}`; const input = page.getByPlaceholder(/type a message|message #flightless/i); await input.fill(testMessage); - await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); const messageEl = page.getByText(testMessage).first(); await expect(messageEl).toBeVisible({ timeout: 15_000 }); diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py index b8e3a72..d9ad184 100644 --- a/tests/test_event_handlers.py +++ b/tests/test_event_handlers.py @@ -239,8 +239,9 @@ class TestContactMessageCLIFiltering: assert len(messages) == 1 assert messages[0].text == "Hello, this is a normal message" - # SHOULD broadcast via WebSocket - mock_broadcast.assert_called_once() + # Placeholder contact is broadcast first, then the message + assert mock_broadcast.call_count == 2 + assert mock_broadcast.call_args_list[-1].args[0] == "message" @pytest.mark.asyncio async def test_broadcast_payload_has_correct_acked_type(self, test_db): @@ -259,9 +260,8 @@ class TestContactMessageCLIFiltering: await on_contact_message(MockEvent()) - # Verify broadcast was called - mock_broadcast.assert_called_once() - call_args = mock_broadcast.call_args + # Last broadcast is the message; first may be placeholder contact + call_args = mock_broadcast.call_args_list[-1] # First arg is event type, second is payload dict event_type, payload = call_args[0] @@ -305,8 +305,7 @@ class TestContactMessageCLIFiltering: await on_contact_message(MockEvent()) - mock_broadcast.assert_called_once() - event_type, payload = mock_broadcast.call_args[0] + event_type, payload = mock_broadcast.call_args_list[-1][0] assert event_type == "message" assert set(payload.keys()) == EXPECTED_MESSAGE_KEYS @@ -360,6 +359,158 @@ class TestContactMessageCLIFiltering: messages = await MessageRepository.get_all() assert len(messages) == 1 + @pytest.mark.asyncio + async def test_unknown_full_key_dm_creates_placeholder_contact(self, test_db): + """Fallback DMs with a full unknown key create a durable placeholder contact.""" + from app.event_handlers import on_contact_message + + full_key = "ab" * 32 + with patch("app.event_handlers.broadcast_event") as mock_broadcast: + + class MockEvent: + payload = { + "public_key": full_key, + "text": "hello from unknown full key", + "txt_type": 0, + "sender_timestamp": 1700000000, + } + + await on_contact_message(MockEvent()) + + contact = await ContactRepository.get_by_key(full_key) + assert contact is not None + assert contact.public_key == full_key + assert contact.name is None + + messages = await MessageRepository.get_all(conversation_key=full_key) + assert len(messages) == 1 + + assert mock_broadcast.call_args_list[0].args[0] == "contact" + assert mock_broadcast.call_args_list[-1].args[0] == "message" + + @pytest.mark.asyncio + async def test_new_contact_promotes_prefix_placeholder_and_broadcasts_resolution(self, test_db): + """NEW_CONTACT promotes prefix placeholders to the full key and emits contact_resolved.""" + from app.event_handlers import on_new_contact + + prefix = "abc123def456" + full_key = prefix + ("00" * 26) + await ContactRepository.upsert( + { + "public_key": prefix, + "type": 0, + "last_seen": 1700000000, + "last_contacted": 1700000000, + "first_seen": 1700000000, + "out_path_hash_mode": -1, + } + ) + await MessageRepository.create( + msg_type="PRIV", + text="hello", + conversation_key=prefix, + sender_timestamp=1700000000, + received_at=1700000000, + ) + + with patch("app.event_handlers.broadcast_event") as mock_broadcast: + + class MockEvent: + payload = { + "public_key": full_key, + "adv_name": "Resolved Sender", + "type": 1, + "flags": 0, + "adv_lat": 0.0, + "adv_lon": 0.0, + "last_advert": 1700000010, + "out_path": "", + "out_path_len": -1, + "out_path_hash_mode": -1, + } + + await on_new_contact(MockEvent()) + + assert await ContactRepository.get_by_key(prefix) is None + resolved = await ContactRepository.get_by_key(full_key) + assert resolved is not None + assert resolved.name == "Resolved Sender" + + messages = await MessageRepository.get_all(conversation_key=full_key) + assert len(messages) == 1 + assert messages[0].conversation_key == full_key + + event_types = [call.args[0] for call in mock_broadcast.call_args_list] + assert "contact" in event_types + assert "contact_resolved" in event_types + + @pytest.mark.asyncio + async def test_new_contact_keeps_ambiguous_prefix_placeholder_unresolved(self, test_db): + """Ambiguous prefix placeholders should not resolve until a unique full-key match exists.""" + from app.event_handlers import on_new_contact + + prefix = "abc123" + full_key = prefix + ("00" * 29) + conflicting_full_key = prefix + ("ff" * 29) + await ContactRepository.upsert( + { + "public_key": prefix, + "type": 0, + "last_seen": 1700000000, + "out_path_hash_mode": -1, + } + ) + await ContactRepository.upsert( + { + "public_key": conflicting_full_key, + "name": "Conflicting Sender", + "type": 1, + "flags": 0, + } + ) + await MessageRepository.create( + msg_type="PRIV", + text="hello from ambiguous prefix", + conversation_key=prefix, + sender_timestamp=1700000000, + received_at=1700000000, + ) + + with patch("app.event_handlers.broadcast_event") as mock_broadcast: + + class MockEvent: + payload = { + "public_key": full_key, + "adv_name": "Resolved Sender", + "type": 1, + "flags": 0, + "adv_lat": 0.0, + "adv_lon": 0.0, + "last_advert": 1700000010, + "out_path": "", + "out_path_len": -1, + "out_path_hash_mode": -1, + } + + await on_new_contact(MockEvent()) + + placeholder = await ContactRepository.get_by_key(prefix) + assert placeholder is not None + + resolved = await ContactRepository.get_by_key(full_key) + assert resolved is not None + assert resolved.name == "Resolved Sender" + + prefix_messages = await MessageRepository.get_all(conversation_key=prefix) + assert len(prefix_messages) == 1 + + resolved_messages = await MessageRepository.get_all(conversation_key=full_key) + assert resolved_messages == [] + + event_types = [call.args[0] for call in mock_broadcast.call_args_list] + assert "contact" in event_types + assert "contact_resolved" not in event_types + @pytest.mark.asyncio async def test_ambiguous_prefix_stores_dm_under_prefix(self, test_db): """Ambiguous sender prefixes should still be stored under the prefix key.""" @@ -400,7 +551,9 @@ class TestContactMessageCLIFiltering: assert len(messages) == 1 assert messages[0].conversation_key == "abc123" - mock_broadcast.assert_called_once() + assert mock_broadcast.call_count == 2 + assert mock_broadcast.call_args_list[0].args[0] == "contact" + assert mock_broadcast.call_args_list[-1].args[0] == "message" _, payload = mock_broadcast.call_args.args assert payload["conversation_key"] == "abc123"