From 18284e15926774890f53ce13e7a91c409a3d897b Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 12 Apr 2026 12:11:00 -0400 Subject: [PATCH] chore: remove CLAUDE.md and stale config files --- BUILD-GUIDE.md | 127 ------------------ CLAUDE.md | 59 -------- DEMO-DEPLOY.md | 46 ------- .../src/api/handler/node_message.rs | 4 +- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../archipelago/src/api/rpc/mesh/messaging.rs | 36 +++++ core/archipelago/src/api/rpc/peers.rs | 4 + core/archipelago/src/mesh/mod.rs | 53 ++++++++ core/archipelago/src/node_message.rs | 16 ++- neode-ui/src/api/rpc-client.ts | 2 +- neode-ui/src/stores/mesh.ts | 26 ++++ neode-ui/src/views/Mesh.vue | 71 ++++++++-- .../src/views/appSession/appSessionConfig.ts | 2 +- 13 files changed, 198 insertions(+), 249 deletions(-) delete mode 100644 BUILD-GUIDE.md delete mode 100644 CLAUDE.md delete mode 100644 DEMO-DEPLOY.md diff --git a/BUILD-GUIDE.md b/BUILD-GUIDE.md deleted file mode 100644 index dfd91a17..00000000 --- a/BUILD-GUIDE.md +++ /dev/null @@ -1,127 +0,0 @@ -# Quick Build Guide - Archipelago Beta Release - -## Prerequisites - -Make sure you have: -- Docker or Podman installed -- `xorriso` installed (for ISO creation) -- Access to dev server: archipelago@192.168.1.228 - -**Note**: When building on the target server with `sudo`, the script will automatically install missing dependencies (`xorriso`, `podman`). - -## Build Auto-Installer ISO - -### Option 1: Build on Target Server (Recommended) - -```bash -# SSH to target server -ssh archipelago@192.168.1.228 - -# Navigate to project -cd ~/archy/image-recipe - -# Run build (auto-installs missing deps) -sudo ./build-auto-installer-iso.sh - -# Copy ISO back to your Mac -# On your Mac: -scp archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-auto-installer-*.iso . -``` - -### Option 2: Build from Mac (requires Docker) - -**Important**: This requires Docker Desktop installed on macOS. - -```bash -cd /Users/dorian/Projects/archy/image-recipe - -# Capture current live server state -DEV_SERVER=archipelago@192.168.1.228 ./build-auto-installer-iso.sh - -# ISO will be created in: results/archipelago-auto-installer-*.iso -``` - -## What the ISO Includes - -✅ Complete Debian 13 root filesystem -✅ Pre-built Archipelago backend -✅ Pre-built frontend (web UI) -✅ **Prepackaged container images** (Bitcoin Knots, LND, UIs, and other bundled apps), loaded on first boot -✅ Nginx configuration (HTTPS ready) -✅ Auto-installer that: - - Detects internal disk - - Creates partitions (EFI + root) - - Extracts pre-built system - - Installs bootloader - - Reboots to working system - -## What Users Need to Do Post-Install - -1. **Start apps from the Web UI** – Container images are prepackaged and loaded on first boot. Bitcoin Knots + UI, LND + UI, and other bundled apps are ready to start from the Web UI without manual `podman run`. No need to pull or deploy core containers. - -2. **Access Web UI** – Navigate to `http://[server-ip]` - -## Testing the ISO - -```bash -# Use VirtualBox, QEMU, or real hardware -qemu-system-x86_64 \ - -m 4G \ - -cdrom results/archipelago-auto-installer-*.iso \ - -hda archipelago-test.qcow2 \ - -boot d -``` - -## Important Notes - -⚠️ **The auto-installer will ERASE the target disk!** -⚠️ Make sure to test on a non-production machine first -⚠️ Minimum 20GB disk space required (500GB+ recommended for Bitcoin) - -## Build from Source (Alternative) - -If you want to build everything from scratch instead of capturing the live server: - -```bash -BUILD_FROM_SOURCE=1 ./build-auto-installer-iso.sh -``` - -This will: -- Build backend from Rust source -- Build frontend with `npm run build` -- Create fresh SSL certificates -- Generate default configs - -## Troubleshooting - -**ISO won't boot:** -- Ensure UEFI mode is enabled -- Try disabling Secure Boot - -**Installer hangs:** -- Check the auto-start script fix is applied (see DEPLOYMENT.md) - -**Backend doesn't detect containers:** -- Verify `/etc/sudoers.d/archipelago-podman` exists -- Check backend can run `sudo podman ps` - -## Version Naming - -ISOs are automatically named with timestamp: -``` -archipelago-auto-installer-YYYYMMDD-HHMMSS.iso -``` - -For releases, rename to: -``` -archipelago-v0.1.0-beta.1.iso -``` - -## Next Steps After Building - -1. Test the ISO on VM -2. Verify web UI loads -3. Test container deployment -4. Document any issues -5. Tag the release in git -6. Upload ISO to distribution point diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a9cc5c47..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,59 +0,0 @@ -# CLAUDE.md — Archipelago (Archy) - -Archipelago is a **Bitcoin Node OS** — bootable, self-sovereign personal server. Flash to USB, install on hardware, manage via web UI. - -**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 13 -**Version**: 1.3.5 | **Target**: x86_64 and ARM64 - -## Beta Freeze (2026-03-18) - -Phase 1: Feature Testing (internal). Feature set is locked. -Only: bug fixes, security hardening, ISO build fixes, UI polish, testing. -Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md` - -## Quick Reference - -```bash -cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100) -cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/) -./scripts/deploy-to-target.sh --live # Deploy to live server (.228) -``` - -## Architecture - -``` -Debian 13 - ├── Podman (rootless, user archipelago) - ├── Nginx (80/443 → backend, app proxies) - ├── Rust Backend (core/) on 127.0.0.1:5678 - └── Vue.js UI (neode-ui/) -``` - -**Data paths**: `/var/lib/archipelago/{app-id}/` (data), `/opt/archipelago/web-ui/` (frontend), `/usr/local/bin/archipelago` (binary) - -## Critical Rules - -1. Do not build Rust on macOS — deploy script handles cross-compilation via rsync + remote build. -2. Always deploy after changes — `./scripts/deploy-to-target.sh --live` -3. Frontend builds to `web/dist/neode-ui/` — not `neode-ui/dist/` -4. Container images: `scripts/image-versions.sh` is the single source of truth. All scripts use `$*_IMAGE` variables, not hardcoded registry paths. -5. Type-check before committing — `cd neode-ui && npx vue-tsc -b --noEmit` - -## App Integration Checklist - -When adding/fixing apps, check all of these: -- `core/archipelago/src/api/rpc/package/` — config, capabilities, deps -- `neode-ui/src/views/marketplace/marketplaceData.ts` — marketplace entry -- `image-recipe/configs/nginx-archipelago.conf` — proxy rules (HTTP + HTTPS) -- `scripts/image-versions.sh` — pinned image version -- `scripts/first-boot-containers.sh` — first boot creation -- `scripts/deploy-to-target.sh` — deploy logic - -## Git - -Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`) -Push to: `git push tx1138 main` - -## Compact Instructions - -When compacting, preserve: list of modified files, test results, deploy target state, current branch, infrastructure IPs. diff --git a/DEMO-DEPLOY.md b/DEMO-DEPLOY.md deleted file mode 100644 index d1e2ec37..00000000 --- a/DEMO-DEPLOY.md +++ /dev/null @@ -1,46 +0,0 @@ -# Demo Deployment via Portainer - -Deploy Archipelago with the **mock backend** for demos. No real node required. - -## Quick Deploy (Portainer) - -1. In Portainer: **Stacks** → **Add stack** -2. Name: `archy-demo` -3. **Web editor** → paste contents of `docker-compose.demo.yml` -4. Or **Build from repository**: use this repo URL and set Compose path to `docker-compose.demo.yml` -5. Deploy - -**Access:** http://your-host:4848 - -## Mock Backend - -- Uses the Node.js mock backend (not the Rust backend) -- Pre-loaded apps, fake data, simulated install/start/stop -- **Login password:** `password123` - -## Port - -Default: **4848**. To change, edit the ports mapping in `docker-compose.demo.yml`: - -```yaml -ports: - - "YOUR_PORT:80" -``` - -## Chat (Claude AI) - -Set `ANTHROPIC_API_KEY` in the Portainer stack environment to enable real AI chat: - -1. In the stack editor, add under **Environment variables**: - - `ANTHROPIC_API_KEY` = your Anthropic API key (starts with `sk-ant-api...`) -2. Redeploy the stack - -Without this key, chat shows a "not configured" error. The key is passed to the `neode-backend` container which proxies requests to `api.anthropic.com`. - -## Dev Mode - -`VITE_DEV_MODE=existing` skips setup/onboarding and goes straight to login. For other flows: - -- `setup` – Password setup screen first -- `onboarding` – Experimental onboarding flow -- `existing` – Login only (default for demo) diff --git a/core/archipelago/src/api/handler/node_message.rs b/core/archipelago/src/api/handler/node_message.rs index 98f7a257..d9c1eaf0 100644 --- a/core/archipelago/src/api/handler/node_message.rs +++ b/core/archipelago/src/api/handler/node_message.rs @@ -9,6 +9,7 @@ impl ApiHandler { #[derive(serde::Deserialize)] struct Incoming { from_pubkey: Option, + from_name: Option, message: Option, signature: Option, #[serde(default)] @@ -67,7 +68,8 @@ impl ApiHandler { tracing::info!("Received message from {}: {}", safe_from, safe_msg); let clean_from = sanitize_html(from); let clean_msg = sanitize_html(&plaintext); - node_msg::store_received(&clean_from, &clean_msg).await; + let clean_name = incoming.from_name.as_deref().map(sanitize_html); + node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await; } Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#))) } diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index abf50c0f..ff53d22b 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -288,6 +288,7 @@ impl RpcHandler { "mesh.peers" => self.handle_mesh_peers().await, "mesh.messages" => self.handle_mesh_messages(params).await, "mesh.send" => self.handle_mesh_send(params).await, + "mesh.send-channel" => self.handle_mesh_send_channel(params).await, "mesh.broadcast" => self.handle_mesh_broadcast().await, "mesh.configure" => self.handle_mesh_configure(params).await, "mesh.send-invoice" => self.handle_mesh_send_invoice(params).await, diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 0a7d1023..365f9ac1 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -40,6 +40,42 @@ impl RpcHandler { })) } + /// mesh.send-channel — Send a text message to a mesh channel (broadcast). + pub(in crate::api::rpc) async fn handle_mesh_send_channel( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + + let channel = params + .get("channel") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u8; + + let message = params + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing message"))?; + + if message.is_empty() { + anyhow::bail!("Message cannot be empty"); + } + + let service = self.mesh_service.read().await; + let svc = service + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?; + + let msg = svc.send_channel_message(channel, message).await?; + info!(channel, "Sent mesh channel message"); + + Ok(serde_json::json!({ + "sent": true, + "message_id": msg.id, + "channel": channel, + })) + } + /// mesh.broadcast — Broadcast our node identity over mesh. pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result { let service = self.mesh_service.read().await; diff --git a/core/archipelago/src/api/rpc/peers.rs b/core/archipelago/src/api/rpc/peers.rs index 6ba9a880..a7896363 100644 --- a/core/archipelago/src/api/rpc/peers.rs +++ b/core/archipelago/src/api/rpc/peers.rs @@ -101,12 +101,16 @@ impl RpcHandler { || format!("{}.onion", n.onion) == onion) .map(|n| n.pubkey.clone()); + // Include our node name so the recipient can display it + let node_name = data.server_info.name.clone(); + node_message::send_to_peer( onion, &pubkey, message, Some(node_id.signing_key()), recipient_pubkey.as_deref(), + node_name.as_deref(), ).await?; Ok(serde_json::json!({ "ok": true, "sent_to": onion })) } diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index f4fd257a..3bccc490 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -509,6 +509,59 @@ impl MeshService { Ok(msg) } + /// Send a message to a mesh channel (broadcast). + /// Routes through the background listener which owns the serial port. + pub async fn send_channel_message(&self, channel: u8, text: &str) -> Result { + let status = self.state.status.read().await; + if !status.device_connected { + anyhow::bail!("No mesh device connected"); + } + drop(status); + + let payload = text.as_bytes().to_vec(); + + if payload.len() > protocol::MAX_MESSAGE_LEN { + anyhow::bail!( + "Message too large for LoRa: {} bytes (max {})", + payload.len(), + protocol::MAX_MESSAGE_LEN + ); + } + + // Send through the listener's command channel + self.state + .cmd_tx + .send(listener::MeshCommand::BroadcastChannel { + channel, + payload, + }) + .await + .map_err(|_| anyhow::anyhow!("Mesh listener not running"))?; + + let chan_contact_id = u32::MAX - (channel as u32); + let chan_name = format!("Channel {}", channel); + let msg_id = self.state.next_id().await; + + let msg = MeshMessage { + id: msg_id, + direction: MessageDirection::Sent, + peer_contact_id: chan_contact_id, + peer_name: Some(chan_name), + plaintext: text.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + delivered: false, + encrypted: false, + }; + + self.state.store_message(msg.clone()).await; + { + let mut status = self.state.status.write().await; + status.messages_sent += 1; + } + + Ok(msg) + } + /// Broadcast our advertisement over mesh so other nodes can discover us. /// Sends an immediate advert via the listener's command channel. pub async fn broadcast_identity(&self) -> Result<()> { diff --git a/core/archipelago/src/node_message.rs b/core/archipelago/src/node_message.rs index f367932b..69bad59d 100644 --- a/core/archipelago/src/node_message.rs +++ b/core/archipelago/src/node_message.rs @@ -14,6 +14,9 @@ pub struct IncomingMessage { pub from_pubkey: String, #[serde(default)] pub from_onion: Option, + /// Sender's node name (for display in group chat). + #[serde(default)] + pub from_name: Option, pub message: String, pub timestamp: String, /// "sent" or "received" @@ -73,7 +76,7 @@ fn persist() { } /// Store a received message (called from HTTP handler). -pub fn store_received_sync(from_pubkey: &str, message: &str) { +pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) { let ts = chrono::Utc::now().to_rfc3339(); let mut guard = store().lock().unwrap_or_else(|e| e.into_inner()); @@ -89,6 +92,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str) { guard.messages.push(IncomingMessage { from_pubkey: from_pubkey.to_string(), from_onion: None, + from_name: from_name.map(|s| s.to_string()), message: message.to_string(), timestamp: ts, direction: "received".to_string(), @@ -98,8 +102,8 @@ pub fn store_received_sync(from_pubkey: &str, message: &str) { persist(); } -pub async fn store_received(from_pubkey: &str, message: &str) { - store_received_sync(from_pubkey, message); +pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) { + store_received_sync(from_pubkey, message, from_name); } /// Store a sent message (for display in Archipelago channel). @@ -231,6 +235,7 @@ pub async fn send_to_peer( message: &str, signing_key: Option<&ed25519_dalek::SigningKey>, recipient_pubkey: Option<&str>, + from_name: Option<&str>, ) -> Result<()> { validate_onion(onion)?; @@ -255,12 +260,15 @@ pub async fn send_to_peer( _ => (message.to_string(), false), }; - let body = serde_json::json!({ + let mut body = serde_json::json!({ "from_pubkey": from_pubkey, "message": payload_message, "timestamp": chrono::Utc::now().to_rfc3339(), "encrypted": encrypted, }); + if let Some(name) = from_name { + body["from_name"] = serde_json::Value::String(name.to_string()); + } let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?; let client = reqwest::Client::builder() diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index bc8c8564..e8894b04 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -398,7 +398,7 @@ class RPCClient { }) } - async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; message: string; timestamp: string }> }> { + async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; from_name?: string; message: string; timestamp: string; direction?: string }> }> { return this.call({ method: 'node-messages-received', params: {}, diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index bd23900e..5ae38b5c 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -276,6 +276,31 @@ export const useMeshStore = defineStore('mesh', () => { return result } + async function sendChannelMessage(channel: number, message: string) { + const doSend = async () => { + try { + sending.value = true + error.value = null + const res = await rpcClient.call<{ sent: boolean; message_id: number; channel: number }>({ + method: 'mesh.send-channel', + params: { channel, message: message.trim() }, + }) + if (res.sent) { + await fetchMessages() + } + return res + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : 'Failed to send channel message' + throw err + } finally { + sending.value = false + } + } + const result = sendQueue.then(doSend, doSend) + sendQueue = result.then(() => {}, () => {}) + return result + } + async function broadcastIdentity() { try { error.value = null @@ -456,6 +481,7 @@ export const useMeshStore = defineStore('mesh', () => { fetchPeers, fetchMessages, sendMessage, + sendChannelMessage, broadcastIdentity, configure, refreshAll, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index d1051b27..bf50dd3a 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -37,18 +37,34 @@ let pollInterval: ReturnType | null = null // The Public channel (always available on Meshcore) const publicChannel = { index: 0, name: 'Public' } +// Channel contact_id convention: matches backend u32::MAX - channel_index +function channelContactId(channelIndex: number): number { + return 4294967295 - channelIndex // u32::MAX - index +} + // Archipelago Channel — Tor-based messaging to all federated/peered nodes const archChannelActive = ref(false) -const archMessages = ref>([]) +const archMessages = ref>([]) const archUnread = ref(0) let archPollInterval: ReturnType | null = null +// Federation node name cache: pubkey -> node name +const fedNodeNames = ref>({}) -function openArchChannel() { +async function openArchChannel() { activeChatPeer.value = null activeChatChannel.value = null archChannelActive.value = true archUnread.value = 0 mobileShowChat.value = true + // Load federation node names for resolving pubkeys to names + try { + const res = await rpcClient.federationListNodes() + const names: Record = {} + for (const node of res.nodes) { + if (node.pubkey) names[node.pubkey] = node.name || node.did.slice(0, 12) + '...' + } + fedNodeNames.value = names + } catch { /* non-fatal */ } loadArchMessages() if (!archPollInterval) { archPollInterval = setInterval(loadArchMessages, 15000) @@ -58,7 +74,18 @@ function openArchChannel() { async function loadArchMessages() { try { const res = await rpcClient.getReceivedMessages() - archMessages.value = res.messages || [] + const newMessages = res.messages || [] + // Track unread: count new received messages since last load + if (archMessages.value.length > 0 && !archChannelActive.value) { + const newReceived = newMessages.filter( + m => m.direction !== 'sent' && m.from_pubkey !== 'me' + && !archMessages.value.some(existing => + existing.from_pubkey === m.from_pubkey && existing.timestamp === m.timestamp + ) + ) + archUnread.value += newReceived.length + } + archMessages.value = newMessages } catch { /* silent */ } } @@ -142,6 +169,11 @@ async function handleToggleOffGrid() { onMounted(async () => { window.addEventListener('resize', handleResize) await Promise.all([mesh.refreshAll(), transport.fetchStatus()]) + // Start background polling for Archipelago (Tor) messages so unread count works + loadArchMessages() + if (!archPollInterval) { + archPollInterval = setInterval(loadArchMessages, 15000) + } pollInterval = setInterval(() => { mesh.fetchStatus() mesh.fetchPeers() @@ -154,6 +186,7 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('resize', handleResize) if (pollInterval) clearInterval(pollInterval) + if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null } }) // Active chat name for the header @@ -177,11 +210,21 @@ const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChann const chatMessages = computed(() => { if (archChannelActive.value) { return archMessages.value.map((m, i) => { - const isSent = (m as Record).direction === 'sent' || m.from_pubkey === 'me' + const isSent = m.direction === 'sent' || m.from_pubkey === 'me' + let peerName = 'Unknown' + if (isSent) { + peerName = 'You' + } else if (m.from_name) { + peerName = m.from_name + } else if (fedNodeNames.value[m.from_pubkey]) { + peerName = fedNodeNames.value[m.from_pubkey] + } else { + peerName = m.from_pubkey.slice(0, 12) + '...' + } return { id: i, peer_contact_id: -99, - peer_name: isSent ? 'You' : (m.from_pubkey.slice(0, 12) + '...'), + peer_name: peerName, direction: (isSent ? 'sent' : 'received') as 'sent' | 'received', plaintext: m.message, timestamp: m.timestamp, @@ -193,7 +236,7 @@ const chatMessages = computed(() => { }) } if (activeChatChannel.value) { - const chanId = -(activeChatChannel.value.index + 1) + const chanId = channelContactId(activeChatChannel.value.index) return mesh.messages.filter(m => m.peer_contact_id === chanId) } if (activeChatPeer.value) { @@ -236,6 +279,7 @@ function openChannelChat(channel: { index: number; name: string }) { messageText.value = '' activeTab.value = 'chat' mobileShowChat.value = true + mesh.markChatRead(channelContactId(channel.index)) nextTick(() => scrollChatToBottom()) } @@ -254,12 +298,18 @@ async function handleSendMessage() { nextTick(() => scrollChatToBottom()) return } - if (!activeChatPeer.value || !messageText.value.trim()) return + if (!messageText.value.trim()) return sendError.value = '' try { - await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value) - messageText.value = '' - nextTick(() => scrollChatToBottom()) + if (activeChatChannel.value) { + await mesh.sendChannelMessage(activeChatChannel.value.index, messageText.value) + messageText.value = '' + nextTick(() => scrollChatToBottom()) + } else if (activeChatPeer.value) { + await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value) + messageText.value = '' + nextTick(() => scrollChatToBottom()) + } } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Send failed' } @@ -467,6 +517,7 @@ function truncatePubkey(hex: string | null): string {
Public
Mesh radio
+ {{ mesh.unreadCounts[channelContactId(0)] }}