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"