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

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