feat(mesh): native-unicast DMs, contact import/remove, reachability, contact search

- DMs now use native meshcore unicast (CMD_SEND_TXT_MSG) instead of @DM2 channel
  broadcasts: private (E2E-encrypted to the recipient pubkey by firmware), off the
  public channel, and decodable by stock clients. Plain text (split, not MC-chunked)
  to non-archipelago contacts; typed envelopes to archy peers.
- !ai replies now DM the asker privately (RadioDm) instead of broadcasting on ch0.
- Auto contact-import: a heard advert (PUSH_CONTACT_ADVERT/0x80, 32-byte pubkey) is
  added via CMD_ADD_UPDATE_CONTACT (0x09) so contacts appear without a flood advert.
- clear-all now DELETES firmware contacts via CMD_REMOVE_CONTACT (0x0F) instead of
  blocklisting; blocking filter removed entirely. Wiped contacts return when reachable.
- Contact reachability: MeshPeer carries last_advert + reachable (path-based); UI shows
  a reachability dot.
- Peers list: contact search box (filter by name/DID/npub/pubkey) with a clear button.
- send_message routes stock contacts as plain native text (fixes garbled envelopes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-18 08:08:52 -04:00
co-authored by Claude Opus 4.8
parent 9f2edf6b7a
commit f0fdc23cc9
15 changed files with 578 additions and 95 deletions
+2
View File
@@ -30,6 +30,8 @@ export interface MeshPeer {
snr: number | null
last_heard: string
hops: number
last_advert?: number
reachable?: boolean
}
export interface MeshChannel {
+41 -1
View File
@@ -501,6 +501,7 @@ interface MergedPeer {
primary_pubkey_hex: string | null
primary_rssi: number | null
is_archy: boolean
reachable: boolean
// The original active-chat marker uses contact_id equality, so keep a
// representative MeshPeer for the rest of the codepaths that still want
// a single object (peer header rssi, prekey rotation, etc).
@@ -622,6 +623,7 @@ const mergedPeers = computed<MergedPeer[]>(() => {
primary_pubkey_hex: peer.pubkey_hex,
primary_rssi: peer.rssi,
is_archy: isArchyNode(peer) || !!matchedFed,
reachable: peer.reachable ?? true,
primary: peer,
})
}
@@ -671,12 +673,27 @@ const mergedPeers = computed<MergedPeer[]>(() => {
primary_pubkey_hex: fed.pubkey,
primary_rssi: null,
is_archy: true,
reachable: true,
primary: placeholder,
})
}
return Array.from(groups.values())
})
// Contact search — filters the Peers list by name, DID, npub, or pubkey.
const peerSearch = ref('')
const displayedPeers = computed<MergedPeer[]>(() => {
const q = peerSearch.value.trim().toLowerCase()
if (!q) return mergedPeers.value
return mergedPeers.value.filter((mp) =>
mp.display_name.toLowerCase().includes(q) ||
(mp.short_did?.toLowerCase().includes(q) ?? false) ||
(mp.did?.toLowerCase().includes(q) ?? false) ||
(mp.npub?.toLowerCase().includes(q) ?? false) ||
(mp.primary_pubkey_hex?.toLowerCase().includes(q) ?? false),
)
})
// Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the
// first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top
// bit, then set it as the federation marker. Producing the SAME id here means a
@@ -1417,6 +1434,25 @@ function isImageMime(mime?: string): boolean {
<button class="text-xs text-white/40 hover:text-red-400 transition-colors px-2 py-1" @click="clearAllMesh" title="Clear all peers, messages, and chat history">Clear All</button>
</div>
<!-- Contact search: filters the list below by name / DID / npub / pubkey -->
<div class="mesh-peer-search-wrap">
<input
v-model="peerSearch"
type="text"
class="mesh-peer-search"
placeholder="Search contacts…"
aria-label="Search contacts"
/>
<button
v-if="peerSearch"
type="button"
class="mesh-peer-search-clear"
aria-label="Clear search"
title="Clear search"
@click="peerSearch = ''"
>&times;</button>
</div>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
No peers discovered yet.
</div>
@@ -1454,8 +1490,11 @@ function isImageMime(mime?: string): boolean {
</div>
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
</div>
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
No contacts match {{ peerSearch.trim() }}.
</div>
<div
v-for="mp in mergedPeers" :key="mp.key"
v-for="mp in displayedPeers" :key="mp.key"
class="mesh-peer-row"
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
tabindex="0"
@@ -1466,6 +1505,7 @@ function isImageMime(mime?: string): boolean {
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
<AnimatedLogo v-if="mp.is_archy" size="sm" />
<template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
<span class="mesh-peer-reach" :class="mp.reachable ? 'is-reachable' : 'is-unreachable'" :title="mp.reachable ? 'Reachable' : 'Not currently reachable'"></span>
</div>
<div class="mesh-peer-info">
<div class="mesh-peer-name">
@@ -62,6 +62,32 @@ function toggleAllowed(pubkey: string) {
apply({ allowed_contacts: allowedContacts.value })
}
// Manually pasting a raw ed25519 pubkey (hex) — for an allowed asker that
// isn't in the contact list yet (e.g. a phone/meshcore device).
const newPubkey = ref('')
const pubkeyError = ref('')
// Allowlisted keys that aren't one of our known contacts (manually added).
const extraAllowed = computed(() =>
allowedContacts.value.filter(
(k) => !contactOptions.value.some((c) => c.pubkey.toLowerCase() === k.toLowerCase()),
),
)
function addPubkey() {
const pk = newPubkey.value.trim().toLowerCase()
pubkeyError.value = ''
if (!/^[0-9a-f]{64}$/.test(pk)) {
pubkeyError.value = 'Enter a 64-character hex ed25519 public key.'
return
}
if (allowedContacts.value.some((k) => k.toLowerCase() === pk)) {
newPubkey.value = ''
return
}
allowedContacts.value = [...allowedContacts.value, pk]
newPubkey.value = ''
apply({ allowed_contacts: allowedContacts.value })
}
onMounted(() => {
mesh.fetchAssistantStatus()
})
@@ -206,7 +232,28 @@ function onPolicy() {
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
</label>
<!-- Manually-added pubkeys not in the contact list -->
<label
v-for="pk in extraAllowed"
:key="pk"
class="mesh-assistant-allow-row"
>
<input type="checkbox" checked @change="toggleAllowed(pk)" />
<span class="mesh-assistant-allow-name" :title="pk">{{ pk.slice(0, 10) }} (added)</span>
</label>
</div>
<!-- Add an arbitrary pubkey directly -->
<div class="mesh-assistant-addkey">
<input
v-model="newPubkey"
class="mesh-bitcoin-input mesh-bitcoin-input-sm"
placeholder="Paste an ed25519 pubkey (64 hex) to allow"
@keyup.enter="addPubkey"
/>
<button type="button" class="glass-button mesh-bitcoin-input-sm" @click="addPubkey">Add</button>
</div>
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
</div>
<p class="text-xs text-white/50 mt-2">
+13 -2
View File
@@ -34,7 +34,7 @@
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; overscroll-behavior: contain; }
.mesh-tools-wrapper { display: contents; }
.mesh-tools-tab-bar { display: none; }
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
@@ -85,7 +85,16 @@
.mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
.mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); }
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
.mesh-peer-avatar { width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 20px; height: 20px; line-height: 18px; text-align: center; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 16px; cursor: pointer; padding: 0; }
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
.mesh-peer-reach.is-reachable { background: #34d399; }
.mesh-peer-reach.is-unreachable { background: rgba(255,255,255,0.25); }
.mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; }
.mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; }
.mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; }
@@ -236,6 +245,8 @@
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-assistant-addkey { display: flex; gap: 6px; margin-top: 6px; }
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }