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
+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: