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:
co-authored by
Claude Opus 4.8
parent
9f2edf6b7a
commit
f0fdc23cc9
@@ -30,6 +30,8 @@ export interface MeshPeer {
|
||||
snr: number | null
|
||||
last_heard: string
|
||||
hops: number
|
||||
last_advert?: number
|
||||
reachable?: boolean
|
||||
}
|
||||
|
||||
export interface MeshChannel {
|
||||
|
||||
@@ -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 = ''"
|
||||
>×</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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user