mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-26 13:01:35 +02:00
Oh god, so much code for such a minor flow. Ambiguous sender manually fetched prefix DMs are now visible.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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' } }));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user