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
+32 -30
View File
@@ -258,43 +258,45 @@ impl RpcHandler {
if let Some(svc) = service.as_ref() {
let state = svc.state();
// Snapshot the firmware pubkeys we currently know about, then
// add them to the radio-contact blocklist. MeshCore's on-device
// contact table is persistent and reads back stale rows on the
// next refresh_contacts, so without this step `clear-all` only
// wipes the app view for a few seconds before the old entries
// reappear. The blocklist is also saved to disk so the filter
// survives a restart.
let firmware_pubkeys: Vec<String> = state
// NOTE: `clear-all` intentionally does NOT build a radio-contact
// blocklist. Permanently ignoring firmware contacts meant a cleared
// peer could never return even when it re-advertised (it also broke
// re-pairing a phone after a clear). Real per-contact blocking will
// be a separate, explicit feature. Here we just wipe the app-side
// view and ALSO clear any blocklist left over from older builds, so
// previously-hidden contacts can re-appear when next heard. The
// firmware's own contact table is the source of truth on refresh.
{
let mut set = state.radio_contact_blocklist.write().await;
set.clear();
}
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
// Actually DELETE each radio contact from the firmware table (via
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
// refresh. They come back only when they re-advertise (reachable).
// Federation-synthetic peers (high contact_id bit) aren't firmware
// contacts, so skip those.
let firmware_pubkeys: Vec<[u8; 32]> = state
.peers
.read()
.await
.values()
.filter_map(|p| {
// Federation-synthetic peers have their contact_id in the
// high half of u32 and carry the archipelago key — those
// aren't firmware contacts and must not go on the list.
if p.contact_id & 0x8000_0000 != 0 {
None
} else {
p.pubkey_hex.clone()
}
.filter(|p| p.contact_id & 0x8000_0000 == 0)
.filter_map(|p| p.pubkey_hex.as_deref())
.filter_map(|h| hex::decode(h).ok())
.filter(|b| b.len() == 32)
.map(|b| {
let mut k = [0u8; 32];
k.copy_from_slice(&b);
k
})
.collect();
{
let mut set = state.radio_contact_blocklist.write().await;
for pk in &firmware_pubkeys {
set.insert(pk.clone());
}
for pk in firmware_pubkeys {
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
.await;
}
let persisted: Vec<String> = state
.radio_contact_blocklist
.read()
.await
.iter()
.cloned()
.collect();
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
state.peers.write().await.clear();
state.messages.write().await.clear();