feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes
UI (this session): - Global audio player now scales the whole interface into the space above it on desktop (sidebar + main) and docks directly above the tab bar on mobile; it stays visible while navigating. - Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip with a single fixed, internally-scrolling pane (page no longer scrolls); tabs hide while a conversation is open; floating back button; collapsible Device panel (starts collapsed); keyboard-aware conversation sizing via VisualViewport so the chat sits just above the keyboard. - Cloud file grid: uniform 4/3 card heights (folders + images match). - Swipe left/right switches tabs on the Apps and Web5 screens. - Map tool fills its pane (no bottom gap); fix skewed Share Location toggle on mobile (global min-height rule was deforming the switch). - Trim redundant helper copy from the mesh AI tab. Also bundles pre-existing in-progress work that was already in the tree: mesh listener/session + wallet + container + bitcoin-status backend changes, docker UI updates, and assorted other UI tweaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4855526fe
commit
1bce694ebb
@ -146,7 +146,9 @@ impl ApiHandler {
|
||||
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
|
||||
hyper::Body::from(
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
|
||||
@ -260,6 +260,20 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
|
||||
// the host's federation peers only — federate first"). Surface that to
|
||||
// the user instead of a bare "Peer returned: 403 Forbidden".
|
||||
if response.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("Peer returned: {status}"));
|
||||
return Err(anyhow::anyhow!(msg));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
}
|
||||
@ -463,12 +477,16 @@ impl RpcHandler {
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
|
||||
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
|
||||
// give Tor a short-but-real window (onion circuits need a few seconds).
|
||||
let path = format!("/content/{}/invoice", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@ -524,11 +542,15 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
|
||||
// FIPS and keep a short Tor window; an unreachable peer just reads as
|
||||
// "not yet paid" and the UI polls again.
|
||||
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@ -652,12 +674,15 @@ impl RpcHandler {
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// Issuing an address is a tiny request/response — fast-fail FIPS, short
|
||||
// Tor window (same budget shape as the invoice path, #6).
|
||||
let path = format!("/content/{}/onchain", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@ -715,7 +740,8 @@ impl RpcHandler {
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
|
||||
@ -156,6 +156,35 @@ impl RpcHandler {
|
||||
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
|
||||
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
|
||||
/// it as hex so it can be used as a stable lookup key and passed in URLs.
|
||||
/// Whether LND reports it's synced to its Bitcoin chain backend. Used to
|
||||
/// fail invoice minting FAST with a clear reason while the node's Bitcoin
|
||||
/// backend is still in initial block download — otherwise the `/v1/invoices`
|
||||
/// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as
|
||||
/// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns
|
||||
/// `Some(false)` only when LND is reachable AND explicitly not synced;
|
||||
/// `None` when we couldn't tell (let the mint attempt proceed and report its
|
||||
/// own error rather than guess "syncing").
|
||||
pub(crate) async fn lnd_chain_synced(&self) -> Option<bool> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await.ok()?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("synced_to_chain").and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
/// Error returned when the node can't mint a Lightning invoice because its
|
||||
/// Bitcoin backend is still syncing. Kept as one string so every invoice
|
||||
/// entry point surfaces the same clear, user-facing reason.
|
||||
fn syncing_invoice_err() -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced."
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_invoice(
|
||||
&self,
|
||||
amount_sats: i64,
|
||||
@ -175,9 +204,12 @@ impl RpcHandler {
|
||||
});
|
||||
// LND's REST endpoint can briefly drop/reset connections under load
|
||||
// (swap pressure, just-restarted, TLS handshake races), which used to
|
||||
// hard-fail the buy-file invoice with an opaque 503. Retry the send a
|
||||
// few times with short backoff so a transient blip doesn't surface as
|
||||
// a payment failure. The surrounding error now carries the real cause.
|
||||
// hard-fail the buy-file invoice with an opaque 503. Retry on a
|
||||
// CONNECTION error with short backoff so a transient blip doesn't
|
||||
// surface as a payment failure. A *timeout* is NOT retried: it means LND
|
||||
// accepted the connection but isn't answering the mint (e.g. a degraded
|
||||
// node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail
|
||||
// after the first hang and let the caller surface the real reason.
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let mut resp = None;
|
||||
for attempt in 0..3u32 {
|
||||
@ -193,10 +225,14 @@ impl RpcHandler {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let timed_out = e.is_timeout();
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"LND REST connect failed (attempt {}): {e}",
|
||||
"LND REST send failed (attempt {}): {e}",
|
||||
attempt + 1
|
||||
));
|
||||
if timed_out {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
}
|
||||
}
|
||||
@ -204,9 +240,15 @@ impl RpcHandler {
|
||||
let resp = match resp {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// If LND is reachable but explicitly not synced to chain, say so —
|
||||
// it's the most common reason a just-restored/syncing node can't
|
||||
// mint. Otherwise surface the underlying transport error.
|
||||
if self.lnd_chain_synced().await == Some(false) {
|
||||
return Err(Self::syncing_invoice_err());
|
||||
}
|
||||
return Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("Failed to reach LND REST to create invoice")
|
||||
}))
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -385,13 +427,23 @@ impl RpcHandler {
|
||||
"memo": memo,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// A hung/failed mint while LND is explicitly not synced to chain
|
||||
// gets a clear, user-facing reason instead of an opaque error.
|
||||
if self.lnd_chain_synced().await == Some(false) {
|
||||
return Err(Self::syncing_invoice_err());
|
||||
}
|
||||
return Err(anyhow::anyhow!(e).context("Failed to create invoice"));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
|
||||
@ -14,12 +14,12 @@ impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let cfg = {
|
||||
let (cfg, denied_askers) = {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
svc.assistant_config().await
|
||||
(svc.assistant_config().await, svc.assistant_denied_askers().await)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
@ -37,6 +37,7 @@ impl RpcHandler {
|
||||
"ollama_detected": ollama_detected,
|
||||
"claude_available": claude_available,
|
||||
"models": models,
|
||||
"denied_askers": denied_askers,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -22,11 +22,23 @@ use tracing::{debug, warn};
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
|
||||
|
||||
// Grace window before a failing poll marks the snapshot "stale" for the UI.
|
||||
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
|
||||
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
|
||||
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
|
||||
// several polls failed in a row — do we surface the banner.
|
||||
const STALE_GRACE_MS: u64 = 20_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
pub ok: bool,
|
||||
pub stale: bool,
|
||||
pub updated_at_ms: u64,
|
||||
// Server-computed age of the snapshot, filled in at serve time. The browser
|
||||
// must not derive this itself (Date.now() - updated_at_ms) because that
|
||||
// compares the browser clock against this node's clock — any skew made a
|
||||
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
|
||||
pub age_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub blockchain_info: Option<serde_json::Value>,
|
||||
pub network_info: Option<serde_json::Value>,
|
||||
@ -40,6 +52,7 @@ impl Default for BitcoinNodeStatus {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: 0,
|
||||
age_ms: 0,
|
||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||
blockchain_info: None,
|
||||
network_info: None,
|
||||
@ -128,7 +141,11 @@ pub fn spawn_status_cache() {
|
||||
|
||||
if cached.blockchain_info.is_some() {
|
||||
cached.ok = false;
|
||||
cached.stale = true;
|
||||
// Only flip to "stale" once the last good snapshot is older
|
||||
// than the grace window. A brief RPC gap on a busy node keeps
|
||||
// showing last-known state silently instead of a banner flicker.
|
||||
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
|
||||
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
|
||||
cached.error = Some(friendly_transient_error(true, &err_msg));
|
||||
} else {
|
||||
*cached = BitcoinNodeStatus {
|
||||
@ -148,12 +165,22 @@ pub fn spawn_status_cache() {
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
cache().read().await.clone()
|
||||
let mut status = cache().read().await.clone();
|
||||
// Compute age here (server clock only) so the browser never has to subtract
|
||||
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
|
||||
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
|
||||
if status.updated_at_ms > 0 {
|
||||
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
|
||||
// but correctly; too tight a timeout turned working-but-slow polls into
|
||||
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
@ -172,6 +199,7 @@ async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
ok: true,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
age_ms: 0,
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info: network_info.ok(),
|
||||
|
||||
@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[(18083, 80)],
|
||||
host_network: false,
|
||||
// Host networking so the app's own nginx can proxy the archipelago backend
|
||||
// same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The
|
||||
// previous bridge + 18083→80 mapping forced the browser to fetch the
|
||||
// backend cross-origin from the app's port, which depended on the host
|
||||
// nginx route + a CORS Origin/Host match and broke on http-only nodes
|
||||
// (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now
|
||||
// listens on 18083 directly (NOT 80 — that would collide with host nginx).
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
@ -439,12 +446,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_ui_uses_port_mapping_not_host_port_80() {
|
||||
fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() {
|
||||
// lnd-ui is host-networked (its nginx listens on 18083 directly) so the
|
||||
// app can proxy the archipelago backend same-origin instead of fetching
|
||||
// it cross-origin from its app port — see the spec comment for why.
|
||||
let spec = &LND_UI[0];
|
||||
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
||||
assert_eq!(u.name, "archy-lnd-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
|
||||
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
|
||||
assert!(matches!(u.network, NetworkMode::Host));
|
||||
assert!(u.ports.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -365,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"fedimint-clientd" | "fmcd" => AppMetadata {
|
||||
title: "Fedimint Client".to_string(),
|
||||
description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(),
|
||||
icon: "/assets/img/app-icons/fedimint.png".to_string(),
|
||||
repo: "https://github.com/minmoto/fmcd".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"morphos" | "morphos-server" => AppMetadata {
|
||||
title: "Morphos".to_string(),
|
||||
description: "Self-hosted file converter".to_string(),
|
||||
|
||||
@ -308,6 +308,14 @@ pub struct PeerRequest<'a> {
|
||||
pub path: &'a str,
|
||||
pub headers: Vec<(&'a str, String)>,
|
||||
pub timeout: std::time::Duration,
|
||||
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
|
||||
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
|
||||
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
|
||||
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
|
||||
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
|
||||
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
|
||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||
pub fips_timeout: Option<std::time::Duration>,
|
||||
pub service: Option<crate::settings::transport::PeerService>,
|
||||
}
|
||||
|
||||
@ -319,10 +327,26 @@ impl<'a> PeerRequest<'a> {
|
||||
path,
|
||||
headers: Vec::new(),
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
|
||||
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
|
||||
/// budget. Use on short request/response calls (invoice, status); leave
|
||||
/// unset for large downloads that legitimately need a long FIPS transfer.
|
||||
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
|
||||
self.fips_timeout = Some(t);
|
||||
self
|
||||
}
|
||||
|
||||
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
|
||||
/// overall request timeout.
|
||||
fn fips_attempt_timeout(&self) -> std::time::Duration {
|
||||
self.fips_timeout.unwrap_or(self.timeout)
|
||||
}
|
||||
|
||||
/// Tie this request to a user-configurable service preference. If
|
||||
/// the user has set that service to `Fips` or `Tor`, the builder
|
||||
/// respects it.
|
||||
@ -423,7 +447,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
@ -456,7 +480,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
|
||||
@ -57,24 +57,32 @@ pub(super) enum AssistReply {
|
||||
|
||||
/// Entry point: gate the query, run the model, send the answer back via the
|
||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_assist(
|
||||
prompt: String,
|
||||
model_override: Option<String>,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
// Whether the asker's message was cryptographically authenticated (a
|
||||
// verified signature, or arrival over the federation transport). Required
|
||||
// for any identity-based allow under `trusted_only`/the allowlist.
|
||||
authenticated: bool,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
// Trust + block gate.
|
||||
if !is_sender_allowed(&state, asker).await {
|
||||
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"AssistQuery denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Record who was turned away so the operator can find + allow them from
|
||||
// the UI (the silent-on-wire denial otherwise only shows in the journal).
|
||||
record_denied(&state, asker, &sender_name).await;
|
||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||
let _ = state
|
||||
.event_tx
|
||||
@ -155,13 +163,25 @@ pub(super) async fn run_assist(
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
/// Always denies user-blocked contacts. With `trusted_only`, requires a
|
||||
/// federation-Trusted match on the peer's pubkey or DID.
|
||||
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
|
||||
///
|
||||
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
|
||||
/// allowlist and the federation-Trusted match) require `authenticated == true` —
|
||||
/// i.e. the asker's message carried a signature that verified against its known
|
||||
/// key (or it arrived over the federation transport, which verifies upstream).
|
||||
/// A bare radio packet can CLAIM any key or DID, so without that proof the
|
||||
/// allowlist and trust list are spoofable; only the explicit "anyone on the
|
||||
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
|
||||
async fn is_sender_allowed(
|
||||
state: &Arc<MeshState>,
|
||||
sender_contact_id: u32,
|
||||
authenticated: bool,
|
||||
) -> bool {
|
||||
let (pubkey_hex, did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&sender_contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
// Match identity on the bound archipelago key (stable, advert/
|
||||
// federation-verified), not the firmware routing key.
|
||||
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
@ -180,20 +200,30 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of
|
||||
// the trusted_only policy (block check above still wins).
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of the
|
||||
// trusted_only policy — but only when the message is authenticated, so a
|
||||
// spoofed packet claiming an allowlisted key can't slip through.
|
||||
if authenticated {
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !state.assistant.read().await.trusted_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trusted-only: match against the federation trust list.
|
||||
// Trusted-only from here: an unauthenticated asker can never match the trust
|
||||
// list (it could otherwise just claim a trusted node's public key/DID).
|
||||
if !authenticated {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match against the federation trust list by the asker's verified archipelago
|
||||
// pubkey or DID (a radio peer gets these from its signed identity advert).
|
||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@ -203,6 +233,36 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
})
|
||||
}
|
||||
|
||||
/// Newest-first cap on the denied-asker buffer — enough to surface the people
|
||||
/// who recently tried, without unbounded growth from a spammer.
|
||||
const MAX_DENIED_ASKERS: usize = 25;
|
||||
|
||||
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
|
||||
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
|
||||
/// timestamp/name) so repeated denials from one device don't flood the list.
|
||||
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
|
||||
// Capture the bound archipelago identity key (NOT the firmware routing key):
|
||||
// one-click "Allow" adds this to the allowlist, which the gate matches on the
|
||||
// archipelago key. A peer with no advert has no arch key → None → the UI shows
|
||||
// "no key" (only the "anyone on the mesh" policy can admit it).
|
||||
let pubkey_hex = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&asker_contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
};
|
||||
let entry = super::DeniedAsker {
|
||||
contact_id: asker_contact_id,
|
||||
name: sender_name.to_string(),
|
||||
pubkey_hex,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let mut denied = state.assist_denied.write().await;
|
||||
denied.retain(|d| d.contact_id != asker_contact_id);
|
||||
denied.push_front(entry);
|
||||
denied.truncate(MAX_DENIED_ASKERS);
|
||||
}
|
||||
|
||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||
/// Returns (text_to_send, was_truncated).
|
||||
fn cap_reply(answer: &str) -> (String, bool) {
|
||||
|
||||
@ -382,7 +382,12 @@ pub(super) async fn store_plain_message(
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st)
|
||||
// A bare plain-text channel `!ai` carries no signature, so it
|
||||
// is NOT authenticated — under trusted_only it'll be denied,
|
||||
// and it can only be answered under the "anyone" policy.
|
||||
super::assist::run_assist(
|
||||
prompt, None, req_id, contact_id, name, false, reply, st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
@ -484,6 +489,10 @@ pub(super) async fn handle_identity_received(
|
||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
// The advert signature was verified above, so this is an authenticated
|
||||
// archipelago identity. Bind it separately so a later refresh_contacts
|
||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
x25519_pubkey: Some(x25519_bytes),
|
||||
rssi: Some(rssi),
|
||||
snr: None,
|
||||
|
||||
@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
sender_name: &str,
|
||||
envelope: TypedEnvelope,
|
||||
) {
|
||||
// Verify envelope signature if present, using the sender's known Ed25519 key
|
||||
// Verify the envelope signature (if present) against the sender's known
|
||||
// Ed25519 key, and record whether the sender is cryptographically
|
||||
// authenticated. A federation peer (synthetic high-half contact_id) arrived
|
||||
// over the Tor relay, which verifies the sender signature upstream before
|
||||
// injecting here, so it counts as authenticated. This flag gates the
|
||||
// identity-based `!ai` allows (allowlist / federation-trust) downstream.
|
||||
let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE;
|
||||
if envelope.sig.is_some() {
|
||||
let peer_pubkey = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&sender_contact_id)
|
||||
.and_then(|p| p.pubkey_hex.as_ref())
|
||||
// Verify against the bound archipelago identity key, not the
|
||||
// firmware routing key — only the former is what the peer signs with.
|
||||
.and_then(|p| p.identity_pubkey_hex())
|
||||
.and_then(|hex_str| hex::decode(hex_str).ok())
|
||||
.and_then(|bytes| {
|
||||
if bytes.len() == 32 {
|
||||
@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
});
|
||||
if let Some(vk) = peer_pubkey {
|
||||
match envelope.verify_signature(&vk) {
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
authenticated = true;
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
peer = sender_contact_id,
|
||||
@ -701,6 +711,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
req_id,
|
||||
cid,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::ChatText { contact_id: cid },
|
||||
st,
|
||||
)
|
||||
@ -748,6 +759,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
query.req_id,
|
||||
sender_contact_id,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::Typed {
|
||||
contact_id: sender_contact_id,
|
||||
},
|
||||
|
||||
@ -153,6 +153,28 @@ pub struct MeshState {
|
||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
|
||||
/// rejects a sender — typically a radio (meshcore) device that presents a
|
||||
/// firmware key rather than an archipelago DID — we record who tried so the
|
||||
/// UI can surface them and let the operator one-click allow their key.
|
||||
/// Silent on the wire (no airtime spent), visible to the operator here.
|
||||
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
|
||||
}
|
||||
|
||||
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
|
||||
/// the operator can add their key to the allowlist without hunting the journal.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeniedAsker {
|
||||
/// Meshcore contact id of the asker.
|
||||
pub contact_id: u32,
|
||||
/// Best-known display name (advert name) at denial time.
|
||||
pub name: String,
|
||||
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
|
||||
/// that hasn't advertised an archipelago key — such a sender can only be
|
||||
/// admitted by switching the policy to "anyone", not via the allowlist.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// ISO-8601 timestamp of the (most recent) denial.
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||
@ -248,6 +270,7 @@ impl MeshState {
|
||||
assistant: RwLock::new(assistant),
|
||||
data_dir,
|
||||
assist_inflight: RwLock::new(HashSet::new()),
|
||||
assist_denied: RwLock::new(VecDeque::new()),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
@ -380,6 +380,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
advert_name: contact.advert_name.clone(),
|
||||
did: existing.and_then(|p| p.did.clone()),
|
||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||
// Preserve any archipelago identity bound by an earlier
|
||||
// identity advert — NEVER overwrite it with the firmware
|
||||
// contact key, or a signed `!ai` query from this peer would
|
||||
// fail authentication after the next contact refresh.
|
||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||
rssi: None,
|
||||
snr: None,
|
||||
|
||||
@ -46,6 +46,12 @@ const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
/// high half of u32 space to avoid collision. Both the receive path
|
||||
/// (`inject_typed_from_federation`) and the startup pre-seed use this
|
||||
/// formula so they always produce the same id for the same peer.
|
||||
/// Mesh contacts at or above this id are synthetic federation peers (the high
|
||||
/// half of the u32 space). Meshcore radio contacts use the firmware's low-int id
|
||||
/// space, so this bit cleanly distinguishes "arrived over the authenticated
|
||||
/// federation transport" from "heard over the radio".
|
||||
pub(crate) const FEDERATION_CONTACT_ID_BASE: u32 = 0x8000_0000;
|
||||
|
||||
pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
|
||||
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
|
||||
if bytes.len() < 4 {
|
||||
@ -77,6 +83,9 @@ pub(crate) async fn upsert_federation_peer(
|
||||
advert_name: display_name,
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
// Federation peers are authenticated by the Tor relay upstream; their
|
||||
// archipelago key is known, so bind it as the identity key too.
|
||||
arch_pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
|
||||
rssi: existing.as_ref().and_then(|p| p.rssi),
|
||||
snr: existing.as_ref().and_then(|p| p.snr),
|
||||
@ -1433,6 +1442,12 @@ impl MeshService {
|
||||
self.state.assistant.read().await.clone()
|
||||
}
|
||||
|
||||
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
|
||||
/// them. Cleared implicitly as new denials rotate older ones out.
|
||||
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
|
||||
self.state.assist_denied.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Update the mesh-AI assistant settings live (no listener restart) and
|
||||
/// persist them to the mesh config. `model: Some(None)` clears the override
|
||||
/// (falls back to the built-in default); `None` leaves a field unchanged.
|
||||
|
||||
@ -32,8 +32,18 @@ pub struct MeshPeer {
|
||||
pub advert_name: String,
|
||||
/// Archipelago DID (did:key:z...) if identity was received.
|
||||
pub did: Option<String>,
|
||||
/// Ed25519 public key hex if identity was received.
|
||||
/// Routing key hex. For a radio (meshcore) peer this is the firmware
|
||||
/// contact public key used to address outbound DMs; for a federation-
|
||||
/// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT
|
||||
/// for authentication — see `arch_pubkey_hex`.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// Verified archipelago ed25519 identity key hex, bound from a signed
|
||||
/// identity advert (`handle_identity_received`) or federation seeding.
|
||||
/// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts`
|
||||
/// with the firmware routing key, so it stays stable for the `!ai` auth
|
||||
/// gate, envelope signature verification, and federation-trust matching.
|
||||
#[serde(default)]
|
||||
pub arch_pubkey_hex: Option<String>,
|
||||
/// X25519 public key (32 bytes) for key agreement.
|
||||
#[serde(skip)]
|
||||
pub x25519_pubkey: Option<[u8; 32]>,
|
||||
@ -56,6 +66,19 @@ pub struct MeshPeer {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
/// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist,
|
||||
/// envelope signature verification): the verified archipelago identity key
|
||||
/// if one is bound, otherwise the routing key. Never use the firmware
|
||||
/// routing key for auth when an archipelago identity is known — a radio
|
||||
/// peer's firmware key won't match its `nodes.json` archipelago key.
|
||||
pub fn identity_pubkey_hex(&self) -> Option<&str> {
|
||||
self.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.or(self.pubkey_hex.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction of a mesh message.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@ -191,3 +214,51 @@ pub enum MeshEvent {
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id: 1,
|
||||
advert_name: "Test".into(),
|
||||
did: None,
|
||||
pubkey_hex: routing.map(|s| s.to_string()),
|
||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: String::new(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() {
|
||||
// A radio peer that sent an identity advert: routing key is the firmware
|
||||
// contact key, but auth must use the bound archipelago key.
|
||||
let p = peer(Some("archkey"), Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_falls_back_to_routing_key_when_no_advert() {
|
||||
// A plain peer with no archipelago identity bound: fall back to whatever
|
||||
// key we have (federation peers carry the arch key in pubkey_hex).
|
||||
let p = peer(None, Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey"));
|
||||
assert_eq!(peer(None, None).identity_pubkey_hex(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_style_routing_update_does_not_change_identity() {
|
||||
// Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new
|
||||
// firmware key while arch_pubkey_hex (identity) is preserved.
|
||||
let mut p = peer(Some("archkey"), Some("firmware-old"));
|
||||
p.pubkey_hex = Some("firmware-new".into());
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -737,6 +737,15 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Snapshot age in ms. Prefer the server-computed age_ms (single clock, no
|
||||
// skew). Fall back to the old browser-vs-server subtraction only for an
|
||||
// older backend that doesn't send age_ms. Mixing clocks was why the
|
||||
// "reconnecting…" banner could stick on nodes whose clock drifted.
|
||||
function snapshotAgeMs(status) {
|
||||
if (typeof status.age_ms === 'number') return status.age_ms;
|
||||
return status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function cookieValue(name) {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
@ -1127,7 +1136,7 @@
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const statusAgeMs = snapshotAgeMs(status);
|
||||
const displayStale = status.stale === true && statusAgeMs > 30000;
|
||||
rpcEl.textContent = displayStale
|
||||
? `Reconnecting on port ${port}`
|
||||
@ -1143,7 +1152,7 @@
|
||||
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
|
||||
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
|
||||
const previousBlockCount = lastBlockCount;
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const statusAgeMs = snapshotAgeMs(status);
|
||||
const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount;
|
||||
const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000;
|
||||
|
||||
|
||||
@ -22,6 +22,6 @@ RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 80
|
||||
EXPOSE 18083
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@ -433,8 +433,12 @@
|
||||
const host = window.location.hostname;
|
||||
|
||||
function getBackendUrl() {
|
||||
// Same-origin by default: the app's own nginx (:18083) proxies these
|
||||
// paths to the archipelago backend, so a relative base ('') avoids
|
||||
// any cross-origin/CORS issues (which broke this on http-only nodes).
|
||||
// ?backend=http://HOST:5678 still overrides for local dev.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('backend') || (window.location.protocol + '//' + window.location.hostname);
|
||||
return params.get('backend') || '';
|
||||
}
|
||||
|
||||
function setSettingsTab(tabId) {
|
||||
@ -499,10 +503,9 @@
|
||||
async function loadLogs() {
|
||||
const logsContent = document.getElementById('logsContent');
|
||||
const backendUrl = getBackendUrl();
|
||||
if (backendUrl) {
|
||||
logsContent.textContent = 'Loading logs...';
|
||||
try {
|
||||
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200');
|
||||
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
|
||||
@ -510,31 +513,26 @@
|
||||
} catch (e) {
|
||||
logsContent.textContent = 'Could not load logs: ' + e.message;
|
||||
}
|
||||
} else {
|
||||
logsContent.textContent = 'Open this app with ?backend=http://HOST:5678 to load logs from the server.';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLiveData() {
|
||||
const backendUrl = getBackendUrl();
|
||||
const data = { channelCount: 0, restReachable: false, grpcReachable: false };
|
||||
if (backendUrl) {
|
||||
try {
|
||||
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo');
|
||||
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo', { credentials: 'include' });
|
||||
if (getinfoRes.ok) {
|
||||
data.getinfo = await getinfoRes.json();
|
||||
data.restReachable = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels');
|
||||
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels', { credentials: 'include' });
|
||||
if (chRes.ok) {
|
||||
const ch = await chRes.json();
|
||||
data.channelCount = (ch.channels && ch.channels.length) || 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
data.grpcReachable = data.restReachable;
|
||||
}
|
||||
applyLiveData(data);
|
||||
}
|
||||
|
||||
@ -629,7 +627,7 @@
|
||||
|
||||
async function fetchConnectInfo() {
|
||||
try {
|
||||
const resp = await fetch(window.location.protocol + '//' + window.location.hostname + '/lnd-connect-info', { credentials: 'include' });
|
||||
const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
if (data.cert_base64url) {
|
||||
|
||||
@ -1,13 +1,63 @@
|
||||
server {
|
||||
listen 80;
|
||||
# Host-networked: listen on the app's own port directly (NOT 80, which the
|
||||
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
|
||||
listen 18083;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# lnd-connect-info is fetched via absolute URL path,
|
||||
# handled by the host nginx → backend at :5678 directly
|
||||
# Proxy the archipelago backend same-origin so the browser never makes a
|
||||
# cross-origin request (no CORS, no host-nginx route dependency). The app is
|
||||
# served on this node's :18083; cookies are scoped by host (not port), so the
|
||||
# browser already carries the `session` (HttpOnly) and `csrf_token` cookies
|
||||
# set by the main UI. We forward both, plus the X-CSRF-Token header, to the
|
||||
# backend on 127.0.0.1:5678 (reachable because this container is host-networked).
|
||||
#
|
||||
# This mirrors fips-ui / electrs-ui. The old bridge + 18083→80 mapping forced
|
||||
# cross-origin fetches that broke on http-only nodes (blank fields, QR
|
||||
# "failed to fetch").
|
||||
location = /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location /proxy/lnd/ {
|
||||
proxy_pass http://127.0.0.1:5678/proxy/lnd/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /api/container/logs {
|
||||
proxy_pass http://127.0.0.1:5678/api/container/logs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
109
docs/session-handoff-2026-06-18.md
Normal file
109
docs/session-handoff-2026-06-18.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Session handoff — 2026-06-18
|
||||
|
||||
> **UPDATE (later same day): ALL OPEN ITEMS RESOLVED + DEPLOYED** (v1.7.99-alpha → .116 + .198).
|
||||
> - **#6 Pay-with-QR timeout** — real bug (both LNDs confirmed healthy by user). FIPS-first dial ate the whole budget before the working Tor fallback ran. Added `PeerRequest.fips_timeout` cap (`fips/dial.rs`); invoice/onchain request+status calls fast-fail FIPS (6s) + short Tor window (25s/15s); frontend ceilings 60s→45s. Large downloads keep the full FIPS timeout.
|
||||
> - **#7 `!ai` gate** — added denied-asker capture (`MeshState.assist_denied`/`DeniedAsker`, `assist.rs::record_denied`) → `mesh.assistant-status.denied_askers` → "Recently denied" list with one-click Allow in `MeshAssistantPanel.vue`.
|
||||
> - **#8 peer-file 403** — NOT a DID reset. Asymmetric federation: .198 had .116 trusted but .116 never added .198. Re-federated (.198 → .116 `nodes.json`, trusted). **Verified:** .116 `/content/<peersonly>` = 403 w/o DID, **200 (177KB png) with .198's DID**. Plus clearer 403 message + client surfaces the body. Listing left visible ("locked preview", user's choice).
|
||||
> - **Dual-ecash receive** — active modal is `ReceiveBitcoinModal.vue` (not the commented-out `Web5SendReceiveModals.vue`); already used dual-detect `wallet.ecash-receive`, fixed Cashu-only wording.
|
||||
> - **fedimint-clientd icon** — `docker_packages.rs` arm → `fedimint.png` + `fedimint-clientd.png` asset.
|
||||
> - **Cashu → 🥜** — `HomeWalletCard.vue`.
|
||||
>
|
||||
> Deploy notes confirmed: binary swap needs atomic `mv` over the running file (`cp` → "Text file busy"); frontend rsync WITHOUT `--delete` to preserve the `aiui/` subdir in `/opt/archipelago/web-ui`.
|
||||
|
||||
|
||||
Resume point for the multi-issue bug-fix + deploy session on **.116** (archi-thinkpad,
|
||||
local dev/validation node) and **.198** (resilience node). Work was done in
|
||||
`~/Projects/archy`. A separate agent's **fedimint dual-ecash** work landed as commit
|
||||
`4288ae78` during the session (don't re-touch `wallet.rs` / `fedimint_client.rs` /
|
||||
`prod_orchestrator.rs` / `Web5SendReceiveModals.vue` without checking with them).
|
||||
|
||||
## DEPLOY STATUS — done
|
||||
|
||||
A surgical deploy (binary + frontend + 2 companion images, **not** the .228-centric
|
||||
`deploy-to-target.sh`, to avoid clobbering .116's custom nginx) shipped to BOTH nodes:
|
||||
|
||||
- **.116**: new binary `/usr/local/bin/archipelago` (backup at `archipelago.bak-pre-deploy-*`),
|
||||
frontend at `/opt/archipelago/web-ui`, `localhost/{lnd-ui,bitcoin-ui}:latest` rebuilt,
|
||||
`:local` tags dropped. Verified: `/bitcoin-status` serves `age_ms`; lnd-ui on `Network=host`
|
||||
listening 18083; `/lnd-connect-info` → 200; both companion containers carry new index.html.
|
||||
- **.198**: same (binary copied — .198 has **no Rust toolchain**, only npm+podman, so
|
||||
build-on-.116-then-copy is mandatory). Verified identically. Force-recreated both companions.
|
||||
|
||||
Build notes: release build ~9 min (opt-level 3). Frontend vite outDir = `web/dist/neode-ui/`
|
||||
(NOT `neode-ui/dist`). Companion images: `ensure_image_present` only builds if image ABSENT,
|
||||
and prefers `localhost/<base>:local` over `:latest` — so to ship docker changes you must drop
|
||||
`:local` and rebuild `:latest`, then the reconciler (`needs_repair` compares rendered quadlet
|
||||
unit vs disk) recreates containers. bitcoin-ui needed an explicit `systemctl --user restart`
|
||||
(its quadlet unit text didn't change, so the reconciler didn't auto-recreate it).
|
||||
|
||||
## FIXED & DEPLOYED
|
||||
|
||||
1. **Mesh chat/peer double-scroll** — `useControllerNav.ts` (wheel scrolls container under
|
||||
pointer, not focused el) + `Mesh.vue` (`@wheel.stop.prevent`).
|
||||
2. **Second-level cloud folder zoom** — `CloudFolder.vue` direction-aware
|
||||
(`cloud-zoom-forward`/`-back`, matched depth-forward/back magnitudes 0.75↔1.2).
|
||||
3. **"FIPS Mesh" → "Fuck IPs Mesh"** — `FipsNetworkCard.vue`, `Server.vue`.
|
||||
4. **.116 connect-wallet QR "failed to fetch"** — lnd-ui migrated to host-network +
|
||||
same-origin nginx proxy: `companion.rs` (host_network:true, ports:[]),
|
||||
`docker/lnd-ui/{Dockerfile(EXPOSE 18083),nginx.conf(listen 18083 + proxy /lnd-connect-info,
|
||||
/proxy/lnd/, /api/container/logs to 127.0.0.1:5678),index.html(getBackendUrl()→'' relative,
|
||||
credentials:'include')}`. ROOT CAUSE was a cross-origin CORS failure (page on :18083 fetching
|
||||
:80). Verified working in incognito; the user's earlier "still broken" was a **stale cached
|
||||
old page**. Unit test `lnd_ui_uses_host_network` passes.
|
||||
5. **.198 Bitcoin Knots stale "reconnecting" banner** — `bitcoin_status.rs` (new server-computed
|
||||
`age_ms` field so the browser never subtracts across clocks; 20s `STALE_GRACE_MS` before
|
||||
flipping stale; RPC timeout 8s→12s) + `docker/bitcoin-ui/index.html` (`snapshotAgeMs()` uses
|
||||
server `age_ms`, falls back to old calc). Two root causes: browser/node clock skew + no grace
|
||||
on single failed polls (swap-thrash node).
|
||||
|
||||
## OPEN ISSUES (diagnosed, NOT fixed)
|
||||
|
||||
6. **"Pay with QR" → request timeout** — full invoice chain intact (hardened in `790da4bd`);
|
||||
60s timeout = seller node never answers (unreachable transport or hung LND). Runtime, needs
|
||||
2 live nodes to repro. NOT a code defect found.
|
||||
|
||||
7. **`!ai` not working** — DIAGNOSED, config fix (awaiting user policy decision). Assistant is
|
||||
`assistant_trusted_only:true` (`/var/lib/archipelago/mesh-config.json`). The trust gate
|
||||
`is_sender_allowed` (mesh/listener/assist.rs) only matches askers by archipelago pubkey/DID
|
||||
against federation-Trusted `nodes.json`, but RADIO (meshcore) askers present a firmware key,
|
||||
not the archipelago identity, so they're silently denied (journal: "AssistQuery denied … from=15
|
||||
name=Arch Optiplex"; federation contact_id ≥ 0x80000000, low ids = radio). Claude key + model
|
||||
(`claude-opus-4-8`) tested HTTP 200 — NOT the problem. FIX: disable trusted_only, or add the
|
||||
asker's presented key to the allowlist. Full notes in memory `project_mesh_ai_trusted_only_gate`.
|
||||
|
||||
8. **Peer-file download .116→.198 "Access denied — federation peer required"** — NEW, NOT yet
|
||||
fixed. Gate at `content.rs:149` (returns on `content_server::ServeResult::Forbidden`). The
|
||||
requesting node isn't recognized as an authorized federation peer by the content server /
|
||||
per-file sharing ACL. User's strong hypothesis: a **DID/identity reset** changed a node's DID,
|
||||
so the sharing ACL / nodes.json holds the OLD identity and no longer matches. User also notes
|
||||
the file is still VISIBLE in the listing (so listing and download use different identity checks
|
||||
— inconsistency to investigate). NEXT: read `content_server` Forbidden logic, compare the
|
||||
requester DID/pubkey vs what's stored; check both nodes' `server_info`/identity vs each other's
|
||||
`federation/nodes.json`. Same THEME as #7 (identity matching) but a different mechanism.
|
||||
|
||||
## NEW FRONTEND REQUESTS (not started — batch into one frontend rebuild+redeploy)
|
||||
|
||||
- **`fedimint-clientd.svg` 404** — new fedimint core-app (`public/catalog.json:294`) has no icon.
|
||||
App-icon convention `/assets/img/app-icons/<id>.png` (default) — add a `fedimint-clientd` icon
|
||||
(there's an existing `fedimint.png` to reuse/adapt). The 404 requests `.svg` so check the
|
||||
catalog/curated-icon entry.
|
||||
- **Cashu icon → cashew emoji** (🥜) — change the cashu wallet icon to a cashew nut emoji.
|
||||
- **Receive › ecash should support BOTH fedimint + cashu paste** — currently the ecash receive
|
||||
only mentions Cashu for pasting a token; user expected the paste box to redeem both Cashu AND
|
||||
Fedimint ecash. Lives in the fedimint agent's recently-committed dual-ecash UI
|
||||
(`Web5SendReceiveModals.vue` / `Web5Wallet.vue` / `WalletSettingsModal.vue`) — investigate what
|
||||
they built before changing.
|
||||
- **Console noise** (lower priority): `cdn.tailwindcss.com` production warning in lnd-ui +
|
||||
bitcoin-ui (uses Tailwind CDN); `api/app-catalog` 502 (check if persistent). Latent backend
|
||||
nicety: `/lnd-connect-info` emits a DOUBLED `Access-Control-Allow-Origin` (backend empty ACAO
|
||||
+ main-nginx `add_header $http_origin`) — harmless on the new same-origin page but should drop
|
||||
the backend's redundant CORS since lnd-ui now fetches same-origin.
|
||||
|
||||
## ENV QUICK-REF
|
||||
|
||||
- .116 archi-thinkpad: data `/var/lib/archipelago`, nginx root `/opt/archipelago/web-ui`,
|
||||
http :80 + custom nginx-proxy-manager; user reaches UI via Tailscale `100.69.68.39` AND LAN.
|
||||
Deploy SSH key `~/.ssh/archipelago-deploy` is passphraseless; SSH-to-self + .198 work non-interactively.
|
||||
- .198: `ssh archipelago@192.168.1.198` (passwordless sudo), podman+npm, NO cargo.
|
||||
- Companion build-dir precedence: `/opt/archipelago/docker` > `~/archy/docker` > `~/Projects/archy/docker`.
|
||||
- Uncommitted working-tree changes (mine, not yet committed): the 11 files for fixes #1–#5.
|
||||
BIN
neode-ui/public/assets/img/app-icons/fedimint-clientd.png
Normal file
BIN
neode-ui/public/assets/img/app-icons/fedimint-clientd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<!-- Spacer to prevent content from being hidden behind the player -->
|
||||
<div v-if="audioPlayer.currentName.value" class="h-14"></div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="audioPlayer.currentName.value"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 audio-player-bar"
|
||||
ref="barEl"
|
||||
class="fixed left-0 right-0 z-40 audio-player-bar"
|
||||
>
|
||||
<!-- Progress bar (clickable) -->
|
||||
<div
|
||||
@ -60,9 +58,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const barEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Publish the player's height as a CSS variable so page scroll containers can
|
||||
// reserve space for it (the same mechanism the mobile tab bar uses). This is
|
||||
// what pushes the rest of the site up instead of letting the fixed bar overlap
|
||||
// and block the bottom controls — on desktop AND mobile, on every page.
|
||||
function setPlayerHeightVar() {
|
||||
if (typeof document === 'undefined') return
|
||||
const h = barEl.value?.offsetHeight || 60
|
||||
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
|
||||
document.documentElement.classList.add('audio-active')
|
||||
}
|
||||
|
||||
function clearPlayerHeightVar() {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.style.setProperty('--audio-player-height', '0px')
|
||||
document.documentElement.classList.remove('audio-active')
|
||||
}
|
||||
|
||||
watch(() => audioPlayer.currentName.value, (name) => {
|
||||
if (name) {
|
||||
nextTick(setPlayerHeightVar)
|
||||
} else {
|
||||
clearPlayerHeightVar()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(clearPlayerHeightVar)
|
||||
|
||||
function togglePlay() {
|
||||
if (audioPlayer.playing.value) {
|
||||
@ -90,6 +117,10 @@ function formatTime(seconds: number): string {
|
||||
|
||||
<style scoped>
|
||||
.audio-player-bar {
|
||||
/* Sit directly above the mobile tab bar (its height is published as
|
||||
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
|
||||
resolves to 0px and the bar docks flush to the bottom of the viewport. */
|
||||
bottom: var(--mobile-tab-bar-height, 0px);
|
||||
background: rgba(15, 15, 15, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
|
||||
@ -471,6 +471,9 @@ onUnmounted(() => {
|
||||
.mesh-map-toggle {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
/* The global mobile rule forces buttons to min-height:44px, which stretches
|
||||
this switch and pushes the knob off-centre. Pin it back to the pill size. */
|
||||
min-height: 20px !important;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
|
||||
</div>
|
||||
@ -119,7 +119,7 @@ async function receive() {
|
||||
if (receiveMethod.value === 'lightning') {
|
||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.addinvoice',
|
||||
method: 'lnd.createinvoice',
|
||||
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
|
||||
})
|
||||
invoiceResult.value = res.payment_request
|
||||
@ -133,11 +133,16 @@ async function receive() {
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else {
|
||||
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||
await rpcClient.call<{ amount_sats: number }>({
|
||||
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
||||
// redeemed at its mint, anything else is reissued as Fedimint notes.
|
||||
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
|
||||
method: 'wallet.ecash-receive',
|
||||
params: { token: ecashToken.value.trim() },
|
||||
})
|
||||
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
|
||||
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||
ecashResult.value = res.received_sats != null
|
||||
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
|
||||
: t('receiveBitcoin.tokenReceivedSuccess')
|
||||
emit('received')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
|
||||
@ -152,11 +152,10 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
|
||||
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
|
||||
|
||||
const aspectClass = computed(() => {
|
||||
if (isImage.value || isVideo.value) return 'aspect-square'
|
||||
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]'
|
||||
return 'aspect-square'
|
||||
})
|
||||
// Uniform card cover ratio across every file type so folders, images, videos
|
||||
// and documents all render at the same height in the grid (previously images/
|
||||
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
|
||||
const aspectClass = computed(() => 'aspect-[4/3]')
|
||||
|
||||
const coverBg = computed(() => {
|
||||
if (props.item.isDir) return 'bg-amber-500/10'
|
||||
|
||||
@ -613,9 +613,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
// ─── Scroll Support ────────────────────────────────────────
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active) return
|
||||
let p = active.parentElement
|
||||
// Scroll the container UNDER THE POINTER, not the focused element. Real
|
||||
// wheel events always target the element beneath the cursor, so walking up
|
||||
// from e.target matches native behaviour. Using document.activeElement here
|
||||
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
|
||||
// peer list, still focused after a click) instead of the panel actually
|
||||
// being hovered — producing a double-scroll where both moved at once.
|
||||
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
|
||||
if (!start) return
|
||||
let p: HTMLElement | null = start
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
|
||||
|
||||
@ -127,6 +127,13 @@ export interface BlockHeader {
|
||||
announced_by: string
|
||||
}
|
||||
|
||||
export interface DeniedAsker {
|
||||
contact_id: number
|
||||
name: string
|
||||
pubkey_hex: string | null
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface AssistantStatus {
|
||||
enabled: boolean
|
||||
model: string | null
|
||||
@ -137,6 +144,7 @@ export interface AssistantStatus {
|
||||
ollama_detected: boolean
|
||||
claude_available: boolean
|
||||
models: string[]
|
||||
denied_askers?: DeniedAsker[]
|
||||
}
|
||||
|
||||
export interface ScheduledMessage {
|
||||
|
||||
@ -130,19 +130,45 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room */
|
||||
/* Height of the global audio player bar — 0 unless it is visible. Set on
|
||||
<html> by GlobalAudioPlayer.vue. Scroll containers add it to their bottom
|
||||
padding so the fixed player pushes content up instead of covering it. */
|
||||
:root {
|
||||
--audio-player-height: 0px;
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room. (On desktop the
|
||||
audio player instead shrinks the whole #main-content area — see the
|
||||
html.audio-active rule below — so no player offset is added here.) */
|
||||
.mobile-scroll-pad,
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
/* Audio player docked: shrink the whole interface into the space above it so
|
||||
the entire view scales up (like the AIUI iframe) instead of just gaining
|
||||
scroll padding. On desktop the player spans full width and BOTH the sidebar
|
||||
and the main content scale into the reduced height above it. Mobile keeps the
|
||||
tab-bar + player handled via .mobile-scroll-pad padding. */
|
||||
@media (min-width: 768px) {
|
||||
html.audio-active .dashboard-view {
|
||||
height: calc(100dvh - var(--audio-player-height, 0px));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Sidebar uses h-screen (100vh) — pin it to the reduced container height. */
|
||||
html.audio-active .dashboard-view [data-controller-zone="sidebar"] {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: override with tab bar clearance */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||
}
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
|
||||
/* Safe area top padding for all mobile content views.
|
||||
@ -174,11 +200,11 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||
}
|
||||
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
|
||||
.mobile-safe-top {
|
||||
@ -525,6 +551,7 @@ input[type="radio"]:active + * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
|
||||
content extending behind browser chrome (address bar / toolbar). */
|
||||
@media (max-width: 767px) {
|
||||
@ -555,8 +582,8 @@ input[type="radio"]:active + * {
|
||||
context) stay above the tab bar instead of sliding underneath it. */
|
||||
@media (max-width: 767px) {
|
||||
.chat-iframe-mobile {
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@ -1926,18 +1953,19 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Mobile floating back/close button (always 8px above tab bar) ──── */
|
||||
/* ── Mobile floating back/close button (always 8px above tab bar — and above
|
||||
the audio player too when it is showing, so it never hides behind it) ── */
|
||||
.mobile-back-btn {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px);
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-btn {
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
|
||||
@ -95,9 +95,11 @@
|
||||
/>
|
||||
<!-- Re-key on the current folder path so the depth/zoom animation replays
|
||||
at every level (folder → subfolder → …), not just on first entry.
|
||||
Only the file content zooms; the header + breadcrumb nav above stay
|
||||
fixed in place. -->
|
||||
<Transition name="cloud-zoom" mode="out-in">
|
||||
The transition name flips with navigation direction so descending
|
||||
zooms forward and going back up zooms in reverse — matching the
|
||||
cloud → folder route transition. Only the file content zooms; the
|
||||
header + breadcrumb nav above stay fixed in place. -->
|
||||
<Transition :name="folderTransition" mode="out-in">
|
||||
<FileGrid
|
||||
:key="cloudStore.currentPath"
|
||||
:items="cloudStore.sortedItems"
|
||||
@ -177,6 +179,19 @@ const cloudStore = useCloudStore()
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// Direction-aware folder zoom: descending into a subfolder plays the same
|
||||
// "depth-forward" feel as the cloud → folder route transition (new arrives from
|
||||
// depth, current zooms out toward the viewer); navigating back up plays its
|
||||
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
|
||||
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
|
||||
let prevFolderDepth = -1
|
||||
watch(() => cloudStore.currentPath, (path) => {
|
||||
const depth = path.split('/').filter(Boolean).length
|
||||
// First render (prevFolderDepth === -1) defaults to forward.
|
||||
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
|
||||
prevFolderDepth = depth
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
@ -395,37 +410,60 @@ function goBack() {
|
||||
</script>
|
||||
|
||||
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
|
||||
element, which lives outside this component's style scope. Matches the
|
||||
`depth-forward` route transition feel (zoom in from depth + blur). -->
|
||||
element, which lives outside this component's style scope. Mirrors the
|
||||
`depth-forward` / `depth-back` route transitions (same scale magnitudes +
|
||||
blur) so descending into a folder and going back up feel identical to the
|
||||
cloud ⇄ folder route change. -->
|
||||
<style>
|
||||
.cloud-zoom-enter-active,
|
||||
.cloud-zoom-leave-active {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition:
|
||||
opacity 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
/* New folder zooms in from depth */
|
||||
.cloud-zoom-enter-from {
|
||||
|
||||
/* Forward (into a deeper folder): new folder arrives from depth while the
|
||||
current one zooms out toward the viewer — matches depth-forward. */
|
||||
.cloud-zoom-forward-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
/* Previous folder recedes forward as it leaves */
|
||||
.cloud-zoom-leave-to {
|
||||
.cloud-zoom-forward-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.12);
|
||||
filter: blur(6px);
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back (up to a parent folder): the mirror — new folder shrinks in from the
|
||||
front while the current one recedes into depth — matches depth-back. */
|
||||
.cloud-zoom-back-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.cloud-zoom-back-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cloud-zoom-enter-active,
|
||||
.cloud-zoom-leave-active {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cloud-zoom-enter-from,
|
||||
.cloud-zoom-leave-to {
|
||||
.cloud-zoom-forward-enter-from,
|
||||
.cloud-zoom-forward-leave-to,
|
||||
.cloud-zoom-back-enter-from,
|
||||
.cloud-zoom-back-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@ -70,6 +70,8 @@
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
@touchstart.passive="onContentTouchStart"
|
||||
@touchend.passive="onContentTouchEnd"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
@ -253,6 +255,72 @@ function activateMainScroll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipe left/right to move between the mobile top tabs ──────────────────
|
||||
// Apps screen: My Apps ⇄ App Store ⇄ Services
|
||||
// Web5 screen: Web5 ⇄ Cloud ⇄ Network ⇄ Mesh
|
||||
// Only active while the matching tab strip is actually showing (mobile).
|
||||
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
|
||||
const APPS_TABS: TabTarget[] = [
|
||||
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
|
||||
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
|
||||
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
|
||||
]
|
||||
const NET_TABS: TabTarget[] = [
|
||||
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
|
||||
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
|
||||
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
|
||||
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
|
||||
]
|
||||
|
||||
function activeAppsKey(): string {
|
||||
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
|
||||
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
|
||||
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
|
||||
return ''
|
||||
}
|
||||
function activeNetKey(): string {
|
||||
const p = route.path
|
||||
if (p.startsWith('/dashboard/web5')) return 'web5'
|
||||
if (p.startsWith('/dashboard/cloud')) return 'cloud'
|
||||
if (p.startsWith('/dashboard/server')) return 'server'
|
||||
if (p.startsWith('/dashboard/mesh')) return 'mesh'
|
||||
return ''
|
||||
}
|
||||
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchStartTime = 0
|
||||
function onContentTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
touchStartX = t.clientX
|
||||
touchStartY = t.clientY
|
||||
touchStartTime = e.timeStamp
|
||||
}
|
||||
function onContentTouchEnd(e: TouchEvent) {
|
||||
const t = e.changedTouches[0]
|
||||
if (!t) return
|
||||
const dx = t.clientX - touchStartX
|
||||
const dy = t.clientY - touchStartY
|
||||
const dt = e.timeStamp - touchStartTime
|
||||
// Clear horizontal flick: far enough, mostly sideways, and quick.
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
|
||||
|
||||
const nav = mobileNavRef.value
|
||||
if (!nav) return
|
||||
let tabs: TabTarget[] | null = null
|
||||
let activeKey = ''
|
||||
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
|
||||
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
|
||||
if (!tabs) return
|
||||
|
||||
const idx = tabs.findIndex(tb => tb.key === activeKey)
|
||||
if (idx < 0) return
|
||||
const next = idx + (dx < 0 ? 1 : -1) // swipe left → next tab, right → previous
|
||||
if (next < 0 || next >= tabs.length) return
|
||||
router.push(tabs[next].to).catch(() => {})
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
|
||||
@ -38,6 +38,8 @@ const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||
const mobileShowChat = ref(false)
|
||||
// Device status panel starts collapsed on mobile (expandable via its header).
|
||||
const deviceExpanded = ref(false)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let wsUnsub: (() => void) | null = null
|
||||
|
||||
@ -261,6 +263,15 @@ const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('cha
|
||||
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
|
||||
|
||||
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
|
||||
// 'chat' shows the peers list / active conversation; the rest swap the pane to
|
||||
// that tool. Selecting a tool leaves any open conversation.
|
||||
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
|
||||
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
|
||||
mobileTab.value = tab
|
||||
if (tab !== 'chat') mobileShowChat.value = false
|
||||
}
|
||||
|
||||
// Panel visibility computeds
|
||||
const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
@ -268,28 +279,29 @@ const showChatPanel = computed(() =>
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value) return mobileTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value) return mobileTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showAssistantPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value) return mobileTab.value === 'assistant'
|
||||
return activeTab.value === 'assistant'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value) return mobileTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||
// Mobile: tool pane shows whenever a non-chat tab is active.
|
||||
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
|
||||
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
|
||||
|
||||
// Fetch session status when active peer changes
|
||||
@ -313,10 +325,25 @@ async function handleToggleOffGrid() {
|
||||
} finally { togglingOffGrid.value = false }
|
||||
}
|
||||
|
||||
// Track the on-screen keyboard height (mobile) so the conversation pane + back
|
||||
// button can sit just above it — fixed elements ignore the keyboard otherwise
|
||||
// and the input ends up hidden behind it. Publishes --keyboard-inset on <html>.
|
||||
function updateKeyboardInset() {
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
const inset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
|
||||
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleDocClickForMenu)
|
||||
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateKeyboardInset)
|
||||
window.visualViewport.addEventListener('scroll', updateKeyboardInset)
|
||||
updateKeyboardInset()
|
||||
}
|
||||
loadPendingFromSession()
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
|
||||
refreshOutboxCount()
|
||||
@ -355,6 +382,11 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleDocClickForMenu)
|
||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
|
||||
window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
|
||||
}
|
||||
document.documentElement.style.removeProperty('--keyboard-inset')
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||
if (wsUnsub) { wsUnsub(); wsUnsub = null }
|
||||
@ -885,10 +917,11 @@ function scrollChatToBottom() {
|
||||
}
|
||||
|
||||
// Wheel over the chat must scroll ONLY the chat — never leak to the contacts
|
||||
// list or the page. CSS overscroll-behavior wasn't enough (the leak happens
|
||||
// even when the chat doesn't overflow), so consume the wheel and apply it to
|
||||
// the chat container directly. Used with `@wheel.prevent` so the default
|
||||
// (page/contacts) scroll never fires.
|
||||
// list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
|
||||
// from reaching the global controller-nav wheel handler (which would otherwise
|
||||
// also scroll whatever container is focused, e.g. the peer list after a click),
|
||||
// and `.prevent` stops the native page scroll. We then apply the delta to the
|
||||
// chat container directly.
|
||||
function onChatWheel(e: WheelEvent) {
|
||||
const el = chatScrollEl.value
|
||||
if (!el) return
|
||||
@ -1354,12 +1387,15 @@ function isImageMime(mime?: string): boolean {
|
||||
<!-- Responsive column layout -->
|
||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
|
||||
<!-- LEFT COLUMN: Status + Peers -->
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat || mobileTab !== 'chat' }">
|
||||
<!-- Device Status -->
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
|
||||
<div class="mesh-status-header">
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card" :class="{ 'mesh-status-collapsed': !deviceExpanded }">
|
||||
<div class="mesh-status-header" role="button" tabindex="0" @click="deviceExpanded = !deviceExpanded" @keydown.enter.prevent="deviceExpanded = !deviceExpanded" @keydown.space.prevent="deviceExpanded = !deviceExpanded">
|
||||
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
|
||||
<h2 class="mesh-section-title">Device</h2>
|
||||
<svg class="mesh-status-chevron" :aria-expanded="deviceExpanded" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div>
|
||||
@ -1543,7 +1579,7 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Tabbed panels -->
|
||||
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat }">
|
||||
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat || mobileTab !== 'chat' }">
|
||||
<!-- Tab bar (medium desktop only) -->
|
||||
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||
@ -1561,15 +1597,29 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card" :class="{ 'mesh-chat-card-active': hasActiveChat }">
|
||||
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||
<div class="mesh-chat-empty-icon">📡</div>
|
||||
<p>Select a peer or channel to chat</p>
|
||||
<p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Mobile: floating back button (shared glass pill style), pinned
|
||||
above the tab bar — replaces the in-header arrow so the back
|
||||
control is no longer crammed inside the chat container. -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
type="button"
|
||||
class="mesh-chat-mobile-back mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium items-center justify-center gap-2"
|
||||
@click="closeChat"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<div class="mesh-chat-header">
|
||||
<button class="mesh-chat-back" @click="closeChat">←</button>
|
||||
<div class="mesh-chat-header-info">
|
||||
<div class="mesh-chat-header-name">
|
||||
<template v-if="renamingActive">
|
||||
@ -1604,7 +1654,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.prevent="onChatWheel">
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
|
||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
@ -1851,17 +1901,6 @@ function isImageMime(mime?: string): boolean {
|
||||
|
||||
<!-- Mobile tools: show under peers list on first view -->
|
||||
<div v-if="showMobileTools" class="mesh-mobile-tools">
|
||||
<div class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
|
||||
</div>
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
@ -1869,6 +1908,23 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: floating tab strip pinned above the global tab bar (same
|
||||
placement as the mobile back button). Switches the whole pane between
|
||||
the chat and each tool. Hidden while an individual conversation is open
|
||||
(the back button takes over there). -->
|
||||
<Teleport to="body">
|
||||
<div v-show="!mobileShowChat" class="mesh-mobile-tabbar">
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'chat' }" @click="selectMobileTab('chat')">Chat</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'bitcoin' }" @click="selectMobileTab('bitcoin')">BTC</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'deadman' }" @click="selectMobileTab('deadman')">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||
|
||||
@ -316,14 +316,14 @@
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payWithInvoice"
|
||||
@click="openQrPay"
|
||||
>
|
||||
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">Pay from another wallet (QR)</span>
|
||||
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
|
||||
<span class="block text-sm text-white/50">Scan an on-chain or Lightning QR with any wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -344,7 +344,52 @@
|
||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Lightning invoice -->
|
||||
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
||||
<div v-else>
|
||||
<!-- Method tabs, styled like the wallet Send/Receive modal -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="m in (['onchain', 'lightning'] as const)"
|
||||
:key="m"
|
||||
@click="selectQrTab(m)"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="qrTab === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ m === 'onchain' ? 'On-chain' : 'Lightning' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- On-chain QR -->
|
||||
<div v-if="qrTab === 'onchain'" class="text-center">
|
||||
<div v-if="onchainWaiting && !onchainData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting an address from the seller…</span>
|
||||
</div>
|
||||
<div v-else-if="onchainData">
|
||||
<div v-if="onchainQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="onchainQr" alt="On-chain payment QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ onchainData.amount_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-orange-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ onchainData.address }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyOnchain">
|
||||
{{ onchainCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40 mt-2">Needs 1 confirmation before the file unlocks.</p>
|
||||
</div>
|
||||
<p v-if="onchainError" class="text-sm text-red-400 mt-3">{{ onchainError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Lightning invoice QR -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
@ -353,7 +398,6 @@
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
@ -373,11 +417,11 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="invoiceError"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-4 w-full"
|
||||
@click="payMode = 'choose'"
|
||||
>
|
||||
Back
|
||||
@ -453,12 +497,21 @@ const audioPlayer = useAudioPlayer()
|
||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||
// they can pay from any external wallet by scanning a QR.
|
||||
const payItem = ref<CatalogItem | null>(null)
|
||||
const payMode = ref<'choose' | 'invoice'>('choose')
|
||||
const payMode = ref<'choose' | 'qr'>('choose')
|
||||
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
|
||||
// on-chain first (the default).
|
||||
const qrTab = ref<'onchain' | 'lightning'>('onchain')
|
||||
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
|
||||
const invoiceQr = ref('')
|
||||
const invoiceWaiting = ref(false)
|
||||
const invoiceError = ref('')
|
||||
const invoiceCopied = ref(false)
|
||||
// On-chain QR (pay the seller's address from any external wallet).
|
||||
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
|
||||
const onchainQr = ref('')
|
||||
const onchainWaiting = ref(false)
|
||||
const onchainError = ref('')
|
||||
const onchainCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
const onchainPaying = ref(false)
|
||||
@ -660,11 +713,17 @@ async function downloadFile(item: CatalogItem) {
|
||||
function openPayModal(item: CatalogItem) {
|
||||
payItem.value = item
|
||||
payMode.value = 'choose'
|
||||
qrTab.value = 'onchain'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceWaiting.value = false
|
||||
invoiceError.value = ''
|
||||
invoiceCopied.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = false
|
||||
onchainError.value = ''
|
||||
onchainCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
onchainPaying.value = false
|
||||
@ -675,9 +734,102 @@ function closePayModal() {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
payItem.value = null
|
||||
invoiceWaiting.value = false
|
||||
onchainWaiting.value = false
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "pay from another wallet" view: tabbed QR like the wallet's
|
||||
* Send/Receive modal, defaulting to the on-chain tab (so a QR is shown
|
||||
* immediately for any external wallet).
|
||||
*/
|
||||
function openQrPay() {
|
||||
payMode.value = 'qr'
|
||||
qrTab.value = 'onchain'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainError.value = ''
|
||||
loadOnchainQr()
|
||||
}
|
||||
|
||||
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
|
||||
* resuming its payment poll if it was already loaded (so switching back and
|
||||
* forth doesn't silently stop watching for payment). */
|
||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
if (qrTab.value === tab) return
|
||||
qrTab.value = tab
|
||||
if (tab === 'onchain') {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
if (!onchainData.value && !onchainWaiting.value) {
|
||||
loadOnchainQr()
|
||||
} else if (onchainData.value && !onchainPaying.value) {
|
||||
onchainPaying.value = true
|
||||
pollOnchain(onchainData.value.address)
|
||||
}
|
||||
} else {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
onchainPaying.value = false
|
||||
if (!invoiceData.value && !invoiceWaiting.value) {
|
||||
payWithInvoice()
|
||||
} else if (invoiceData.value) {
|
||||
scheduleInvoicePoll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-chain QR: ask the seller for a fresh address + amount, render a
|
||||
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
|
||||
* lands, then release the file (the address is the gate token).
|
||||
*/
|
||||
async function loadOnchainQr() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
onchainError.value = ''
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = true
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
onchainError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
onchainWaiting.value = false
|
||||
return
|
||||
}
|
||||
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
|
||||
const btc = (req.amount_sats / 1e8).toFixed(8)
|
||||
try {
|
||||
onchainQr.value = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${btc}`, { margin: 1, width: 240 })
|
||||
} catch {
|
||||
onchainQr.value = '' // fall back to showing the raw address
|
||||
}
|
||||
onchainWaiting.value = false
|
||||
onchainPaying.value = true // "waiting for payment" — drives the poll loop
|
||||
pollOnchain(req.address)
|
||||
} catch (e: unknown) {
|
||||
onchainError.value = e instanceof Error ? e.message : 'Could not request an on-chain address'
|
||||
onchainWaiting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOnchain() {
|
||||
if (!onchainData.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(onchainData.value.address)
|
||||
onchainCopied.value = true
|
||||
setTimeout(() => { onchainCopied.value = false }, 1500)
|
||||
} catch { /* clipboard denied */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
|
||||
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
|
||||
@ -695,7 +847,7 @@ async function payOnchain() {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
@ -805,14 +957,14 @@ async function payWithInvoice() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
|
||||
payMode.value = 'invoice'
|
||||
payMode.value = 'qr'
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!res?.bolt11 || !res?.payment_hash) {
|
||||
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
@ -849,7 +1001,7 @@ async function payWithLightning() {
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
|
||||
@ -136,7 +136,7 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span class="text-white/80 text-sm">FIPS Mesh</span>
|
||||
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
|
||||
</div>
|
||||
|
||||
@ -122,9 +122,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
|
||||
<span class="text-sm text-white/80">Cashu</span>
|
||||
</div>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
|
||||
@ -88,6 +88,22 @@ function addPubkey() {
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
|
||||
// Radio/peers who recently tried `!ai` and were turned away by the policy.
|
||||
// Surfaced so the operator can one-click allow them instead of digging through
|
||||
// the journal for the firmware key. Hide any we've since allowed.
|
||||
const deniedAskers = computed(() =>
|
||||
(status.value?.denied_askers ?? []).filter(
|
||||
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
|
||||
),
|
||||
)
|
||||
function allowDenied(pubkey: string | null) {
|
||||
if (!pubkey) return
|
||||
if (!isAllowed(pubkey)) {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mesh.fetchAssistantStatus()
|
||||
})
|
||||
@ -138,7 +154,6 @@ function onPolicy() {
|
||||
<template>
|
||||
<div class="glass-card mesh-assistant-panel">
|
||||
<h3 class="mesh-panel-title">AI Assistant</h3>
|
||||
<p class="mesh-panel-sub">Answer questions over the mesh with AI</p>
|
||||
|
||||
<!-- Backend chooser -->
|
||||
<div class="mesh-assistant-field">
|
||||
@ -206,10 +221,8 @@ function onPolicy() {
|
||||
<option value="trusted">Trusted nodes only</option>
|
||||
<option value="anyone">Anyone on the mesh</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/40 mt-1">
|
||||
{{ policy === 'anyone'
|
||||
? 'Any peer can spend this node\'s AI budget + airtime.'
|
||||
: 'Only federation-trusted peers may ask.' }}
|
||||
<p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
|
||||
Any peer can spend this node's AI budget + airtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -217,9 +230,6 @@ function onPolicy() {
|
||||
policy is "trusted only" and they aren't federation-trusted. -->
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Always allow these contacts</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
Listed contacts can use <code>!ai</code> regardless of the policy above.
|
||||
</p>
|
||||
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
|
||||
No contacts yet — they appear here once you have mesh/federation contacts.
|
||||
</div>
|
||||
@ -256,6 +266,38 @@ function onPolicy() {
|
||||
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Recently denied askers: someone tried !ai but the policy turned them
|
||||
away. Show who, and offer a one-click Allow when we know their key. -->
|
||||
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Recently denied</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
|
||||
</p>
|
||||
<div class="mesh-assistant-allowlist">
|
||||
<div
|
||||
v-for="d in deniedAskers"
|
||||
:key="d.contact_id + (d.pubkey_hex || '')"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
|
||||
{{ d.name || ('#' + d.contact_id) }}
|
||||
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}…</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="d.pubkey_hex"
|
||||
type="button"
|
||||
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
|
||||
@click="allowDenied(d.pubkey_hex)"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
|
||||
no key
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||
</p>
|
||||
|
||||
@ -62,6 +62,10 @@
|
||||
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
|
||||
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
|
||||
/* Collapse chevron — only used on mobile (hidden on desktop, where the Device
|
||||
panel is always expanded). Kept small and pushed to the far right. */
|
||||
.mesh-status-chevron { display: none; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
|
||||
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
|
||||
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
|
||||
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
@ -90,7 +94,7 @@
|
||||
.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 { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; 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; }
|
||||
@ -120,7 +124,9 @@
|
||||
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
|
||||
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.2); }
|
||||
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
|
||||
.mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; }
|
||||
/* Floating mobile back button (Teleported to body). Hidden by default; only
|
||||
shown in the single-column mobile mesh layout (see media query below). */
|
||||
.mesh-chat-mobile-back { display: none; }
|
||||
.mesh-chat-header-info { flex: 1; min-width: 0; }
|
||||
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
|
||||
@ -155,22 +161,115 @@
|
||||
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-mobile-back-btn { display: none; }
|
||||
|
||||
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
|
||||
≤1279px block flips it to flex and the placement mirrors the mobile back
|
||||
button (pinned above the global tab bar + audio player). */
|
||||
.mesh-mobile-tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.mesh-mtab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
|
||||
.mesh-columns { flex-direction: column; overflow: visible; }
|
||||
.mesh-left { width: 100%; overflow: visible; }
|
||||
.mesh-right { min-height: auto; overflow: visible; }
|
||||
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Single-column mobile mesh: one fixed, internally-scrolling pane that
|
||||
fills the space between the top tab strip and the floating mesh tab bar.
|
||||
The page itself never scrolls; each pane scrolls inside its own bounds.
|
||||
Fixed positioning is relative to the full-height perspective container, so
|
||||
the offsets line up with the body-teleported tab bar / back button. ──── */
|
||||
.mesh-left,
|
||||
.mesh-mobile-tools,
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
/* width:auto so left+right govern the box — .mesh-left otherwise carries a
|
||||
fixed 380px width that ignores `right` and overflows the screen. */
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
|
||||
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 30;
|
||||
}
|
||||
/* Active conversation: the floating tabs are hidden here and the back button
|
||||
takes the standard spot above the tab bar, so the chat window fills down to
|
||||
just above the back pill (back pill ≈44px at +8, plus a 16px gap). When the
|
||||
keyboard is up it covers the tab bar, so anchor to whichever is taller — the
|
||||
bottom controls or the keyboard — so the window sits right above both. */
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
|
||||
overflow: hidden; /* the messages list inside does the scrolling */
|
||||
}
|
||||
.mesh-tools-wrapper { display: none !important; }
|
||||
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
|
||||
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-assistant-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
|
||||
.mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* The active tool fills the whole fixed pane (no fixed cap that would leave a
|
||||
bottom margin); the panel itself scrolls if its content is taller. */
|
||||
.mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
|
||||
.mesh-mobile-tools .mesh-bitcoin-panel,
|
||||
.mesh-mobile-tools .mesh-assistant-panel,
|
||||
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
|
||||
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.mesh-chat-back { display: block; }
|
||||
/* In a conversation the tabs are hidden, so the back pill sits just above the
|
||||
tab bar — or above the keyboard when it's up, whichever is taller. */
|
||||
.mesh-chat-mobile-back { display: flex; }
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
|
||||
}
|
||||
/* Floating mesh tab strip — same placement logic as the mobile back button. */
|
||||
.mesh-mobile-tabbar { display: flex; }
|
||||
.mobile-hidden { display: none !important; }
|
||||
/* Device panel is a collapsible/expandable accordion on mobile (starts
|
||||
collapsed). Show the chevron, make the header tappable, and hide the body
|
||||
when collapsed. */
|
||||
.mesh-status-chevron { display: block; }
|
||||
.mesh-status-card .mesh-status-header { cursor: pointer; margin-bottom: 12px; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
|
||||
:deep(.mesh-bitcoin-panel),
|
||||
:deep(.mesh-assistant-panel),
|
||||
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
|
||||
@ -181,6 +280,14 @@
|
||||
.mesh-view {
|
||||
padding: 24px;
|
||||
}
|
||||
/* In this range the desktop sidebar (256px) is still shown. The in-pane
|
||||
fixed elements are positioned relative to the main content area (their
|
||||
perspective containing block), so they already clear the sidebar — but the
|
||||
body-teleported floating bars are viewport-relative, so nudge them right. */
|
||||
.mesh-mobile-tabbar,
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
left: 268px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
|
||||
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user