use wasm_bindgen::JsCast;
use crate::app::{dom, templates};
pub(super) fn save_prompt_pressed() {
let Some(textarea) = dom::textarea_by_id("prompt-input") else { return };
let content = textarea.value();
dom::swap_inner(
"prompt-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match crate::app::system_prompt::save(&content).await {
Ok(()) => {
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Accent, "✓ saved · using default on next session"),
);
return;
}
let summary = match publish_persona_onchain(&trimmed).await {
Ok(true) => {
"✓ saved + published on-chain · takes effect on next session".to_string()
}
Ok(false) => "✓ saved · takes effect on next session".to_string(),
Err(e) => format!("✓ saved locally · on-chain publish failed: {e}"),
};
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Accent, &summary),
);
}
Err(err) => {
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Error, &err.to_string()),
);
}
}
});
}
async fn publish_persona_onchain(text: &str) -> Result<bool, String> {
let Some(tenant) = crate::app::tenant::current_name() else {
return Ok(false);
};
let token_id = match crate::app::registry::id_of_name(&tenant).await {
Ok(id) if id != 0 => id,
Ok(_) => return Ok(false),
Err(e) => return Err(format!("id_of_name: {e}")),
};
let (_, owner) = crate::app::tenant::current_tenant_owner().await?;
let registry_addr = crate::encoding::parse_address(crate::app::registry::REGISTRY_ADDRESS())?;
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: crate::app::registry::encode_set_persona(token_id, text),
};
let gas = crate::app::gas::set_metadata_gas(text.len());
super::run_sponsored_tempo_call(&owner, vec![call], gas, "publish persona")
.await
.map(|_| true)
}
pub(super) fn save_tool_allowlist_pressed() {
use crate::types::BuiltinTool;
let mut enabled = Vec::new();
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
if input.checked() {
if let Some(name) = input.get_attribute("data-tool") {
if let Some(tool) = BuiltinTool::ALL.iter().find(|t| t.wire_name() == name) {
enabled.push(*tool);
}
}
}
}
}
}
}
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match crate::app::tool_allowlist::save(&enabled).await {
Ok(()) => {
let summary = crate::app::tool_allowlist::summary(&enabled);
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Accent, &format!("✓ saved · {summary} · takes effect on next session")),
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Error, &err.to_string()),
);
}
}
});
}
pub(super) fn reset_tool_allowlist_pressed() {
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--muted)\">resetting…</span>",
);
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
input.set_checked(true);
}
}
}
}
wasm_bindgen_futures::spawn_local(async move {
match crate::app::tool_allowlist::save(&[]).await {
Ok(()) => {
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--accent)\">✓ reset · all tools enabled · takes effect on next session</span>",
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Error, &err.to_string()),
);
}
}
});
}
pub(super) fn save_api_key_pressed() {
if crate::app::is_visitor() {
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--error)\">only the owner can set a key</span>",
);
return;
}
let Some(input) = dom::input_by_id("api-key-input") else { return };
let value = input.value().trim().to_string();
if value.is_empty() {
return;
}
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &value);
}
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--muted)\">checking…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
crate::app::key_store::save(&value).await;
crate::app::opfs::refresh().await;
if let Some(false) = gemini_key_is_valid(&value).await {
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--error)\">key rejected — check it</span>",
);
return;
}
if let Some(el) = dom::by_id("api-key-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
if let Some(name) = crate::app::tenant::current_name() {
super::key_sync::auto_sync_gemini_key(name, value).await;
}
});
}
async fn gemini_key_is_valid(key: &str) -> Option<bool> {
let url = format!("https://generativelanguage.googleapis.com/v1beta/models?key={key}");
match reqwest::Client::new().get(&url).send().await {
Ok(resp) => Some(resp.status().is_success()),
Err(_) => None,
}
}
pub(super) fn save_x402_price_pressed() {
let Some(input) = dom::input_by_id("x402-price-input") else {
return;
};
let raw = input.value().trim().to_string();
wasm_bindgen_futures::spawn_local(async move {
let fs = crate::app::shared_opfs();
let local: Result<u128, String> = async {
let wei = if raw.is_empty() {
0
} else {
crate::encoding::parse_token_amount(&raw)
.ok_or_else(|| format!("'{raw}' is not a $LH amount"))?
};
if wei == 0 {
let _ = fs.delete(".lh_x402_price").await;
} else {
fs.write_atomic(".lh_x402_price", wei.to_string().as_bytes())
.await
.map_err(|e| e.to_string())?;
}
Ok(wei)
}
.await;
let wei = match local {
Ok(wei) => wei,
Err(e) => {
dom::swap_inner(
"x402-price-msg",
&dom::msg_span(dom::Msg::Error, &format!("save failed: {e}")),
);
return;
}
};
match publish_x402_price_onchain(wei).await {
Ok(true) => dom::swap_inner(
"x402-price-msg",
"<span style=\"color:var(--muted)\">saved + published on-chain</span>",
),
Ok(false) => dom::swap_inner(
"x402-price-msg",
"<span style=\"color:var(--muted)\">saved</span>",
),
Err(e) => dom::swap_inner(
"x402-price-msg",
&dom::msg_span(
dom::Msg::Error,
&format!("saved locally · on-chain publish failed: {e}"),
),
),
}
});
}
async fn publish_x402_price_onchain(wei: u128) -> Result<bool, String> {
let Some(tenant) = crate::app::tenant::current_name() else {
return Ok(false);
};
let token_id = match crate::app::registry::id_of_name(&tenant).await {
Ok(id) if id != 0 => id,
Ok(_) => return Ok(false),
Err(e) => return Err(format!("id_of_name: {e}")),
};
let (_, owner) = crate::app::tenant::current_tenant_owner().await?;
let registry_addr = crate::encoding::parse_address(crate::app::registry::REGISTRY_ADDRESS())?;
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: crate::app::registry::encode_set_x402_price(token_id, wei),
};
let gas = crate::app::gas::set_metadata_gas(40);
super::run_sponsored_tempo_call(&owner, vec![call], gas, "publish x402 price")
.await
.map(|_| true)
}
pub(super) fn notif_panel_open() -> bool {
dom::by_id("notif-bell-panel")
.map(|e| !e.has_attribute("hidden"))
.unwrap_or(false)
}
pub(super) fn close_notif_panel() {
dom::swap_outer(
"notif-bell-panel",
&templates::notif_list_panel(&crate::app::notifications::bell_items(), None, true, false)
.into_string(),
);
}
pub(super) fn feedback_panel_open() -> bool {
dom::by_id("feedback-panel")
.map(|e| !e.has_attribute("hidden"))
.unwrap_or(false)
}
pub(super) fn close_feedback_panel() {
dom::swap_outer("feedback-panel", &templates::feedback_panel(true).into_string());
}
pub(super) fn toggle_feedback_panel() {
if feedback_panel_open() {
close_feedback_panel();
return;
}
close_all_header_overlays(); dom::swap_outer("feedback-panel", &templates::feedback_panel(false).into_string());
dom::focus_first_in("feedback-panel");
}
pub(super) fn brand_menu_open() -> bool {
dom::document()
.ok()
.and_then(|d| d.query_selector(".brand-menu[open]").ok().flatten())
.is_some()
}
pub(super) fn close_brand_menu() {
if let Some(el) = dom::document()
.ok()
.and_then(|d| d.query_selector(".brand-menu[open]").ok().flatten())
{
let _ = el.remove_attribute("open");
}
}
pub(super) fn header_admin_open() -> bool {
dom::by_id("admin-dialog").is_some()
}
fn close_all_header_overlays() {
close_notif_panel();
close_feedback_panel();
header_admin_close();
close_brand_menu();
}
pub(super) fn notif_clear_all_pressed() {
dom::swap_outer(
"notif-bell-panel",
&templates::notif_list_panel(&crate::app::notifications::bell_items(), None, false, true)
.into_string(),
);
}
pub(super) fn notif_clear_cancelled() {
dom::swap_outer(
"notif-bell-panel",
&templates::notif_list_panel(&crate::app::notifications::bell_items(), None, false, false)
.into_string(),
);
}
pub(super) fn notif_clear_confirmed() {
crate::app::notifications::clear_all();
}
pub(super) fn notif_bell_pressed() {
if notif_panel_open() {
close_notif_panel();
return;
}
close_all_header_overlays(); let items = crate::app::notifications::bell_items();
dom::swap_outer(
"notif-bell-panel",
&templates::notif_list_panel(&items, None, false, false).into_string(),
);
crate::app::notifications::clear_bell_badge();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = crate::app::notifications::enable_device_push().await {
web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!(
"[push] device registration failed: {e}"
)));
let items = crate::app::notifications::bell_items();
dom::swap_outer(
"notif-bell-panel",
&templates::notif_list_panel(&items, Some(&format!("⚠ {e}")), false, false)
.into_string(),
);
}
});
}
pub(super) fn toggle_telemetry_pressed() {
let now_on = !crate::app::telemetry::enabled();
crate::app::telemetry::set_enabled(now_on);
dom::swap_inner(
"telemetry-toggle",
if now_on { "telemetry: on" } else { "telemetry: off" },
);
}
pub(super) fn toggle_feedback_onchain_pressed() {
let now_on = !crate::app::feedback::feedback_onchain_enabled();
crate::app::feedback::set_feedback_onchain(now_on);
dom::swap_inner(
"feedback-onchain-toggle",
if now_on { "feedback on-chain: on" } else { "feedback on-chain: off" },
);
}
pub(super) fn enable_notifications_pressed() {
wasm_bindgen_futures::spawn_local(async move {
let msg = "notify-msg";
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">enabling…</span>");
match crate::app::notifications::enable_and_publish().await {
Ok(_tx) => dom::swap_inner(
msg,
"<span style=\"color:var(--muted)\">notifications on — push subscription published on-chain</span>",
),
Err(e) => dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &e)),
}
});
}
pub(super) fn test_notification_pressed() {
wasm_bindgen_futures::spawn_local(async move {
let msg = "notify-msg";
crate::app::notifications::vibrate(200);
match crate::app::notifications::ensure_permission().await {
Ok(true) => match crate::app::notifications::show(
"localharness test",
"notifications are working on this device",
)
.await
{
Ok(()) => dom::swap_inner(
msg,
"<span style=\"color:var(--muted)\">test notification sent — check your shade</span>",
),
Err(e) => dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &e)),
},
Ok(false) => dom::swap_inner(
msg,
&dom::msg_span(
dom::Msg::Error,
"notification permission is blocked — allow notifications for this site in the browser settings, then retry",
),
),
Err(e) => dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &e)),
}
});
}
pub(super) fn install_app_pressed() {
wasm_bindgen_futures::spawn_local(async move {
let msg = "install-msg";
let window = web_sys::window().expect("window");
let stash = js_sys::Reflect::get(&window, &"__lhInstall".into()).ok();
let evt = stash.filter(|v| !v.is_null() && !v.is_undefined());
match evt {
Some(evt) => {
let prompt = js_sys::Reflect::get(&evt, &"prompt".into()).ok();
match prompt.and_then(|p| p.dyn_into::<js_sys::Function>().ok()) {
Some(f) => {
let _ = f.call0(&evt);
dom::swap_inner(
msg,
"<span style=\"color:var(--muted)\">follow the browser's install dialog</span>",
);
}
None => dom::swap_inner(
msg,
&dom::msg_span(dom::Msg::Error, "install prompt unavailable"),
),
}
}
None => {
dom::swap_inner(
msg,
"<span style=\"color:var(--muted)\">already installed, or this \
browser hides the prompt — use the browser menu's install / \
add-to-home-screen entry</span>",
);
}
}
});
}
pub(super) fn header_admin_toggle() {
if header_admin_open() {
header_admin_close();
return;
}
close_all_header_overlays(); let body = match crate::app::tenant::current() {
crate::app::tenant::Host::Apex => templates::admin_dropdown_apex().into_string(),
crate::app::tenant::Host::Tenant(_) | crate::app::tenant::Host::Other(_) => {
templates::admin_dropdown_tenant().into_string()
}
};
dom::remember_focus();
dom::swap_outer("header-admin-panel", &body);
dom::focus_first_in("header-admin-panel");
if let Some(card) = crate::app::APP.with(|c| c.borrow().financial_card_html.clone()) {
if dom::by_id("financial-slot").is_some() {
dom::swap_outer("financial-slot", &card);
if let Some(n) = crate::app::APP.with(|c| c.borrow().agent_tool_count) {
dom::swap_outer("tools-count", &templates::tools_count_span(n));
}
}
}
wasm_bindgen_futures::spawn_local(async move {
super::refresh_credits_pill().await;
});
if matches!(crate::app::tenant::current(), crate::app::tenant::Host::Apex) {
wasm_bindgen_futures::spawn_local(async move {
super::devices::refresh_signer_list().await;
});
}
if matches!(
crate::app::tenant::current(),
crate::app::tenant::Host::Tenant(_) | crate::app::tenant::Host::Other(_)
) {
if let Ok(Some(storage)) = dom::session_storage() {
if let Ok(Some(cached)) = storage.get_item("gemini_api_key") {
if let Some(input) = dom::input_by_id("key") {
input.set_value(&cached);
super::refresh_keymeta();
}
}
}
wasm_bindgen_futures::spawn_local(async move {
if let Some(persisted) = crate::app::key_store::load().await {
if let Some(input) = dom::input_by_id("key") {
input.set_value(&persisted);
super::refresh_keymeta();
}
}
if let Some(prompt) = crate::app::system_prompt::load().await {
if let Some(textarea) = dom::textarea_by_id("prompt-input") {
textarea.set_value(&prompt);
}
}
{
if let Ok(bytes) = crate::app::shared_opfs().read(".lh_x402_price").await {
if let Some(wei) = String::from_utf8(bytes)
.ok()
.and_then(|s| s.trim().parse::<u128>().ok())
{
if let Some(input) = dom::input_by_id("x402-price-input") {
input.set_value(&crate::app::format_wei_as_test_eth(wei));
}
}
}
}
if let Some(allowed) = crate::app::tool_allowlist::load().await {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
if let Some(name) = input.get_attribute("data-tool") {
let is_allowed = allowed.iter().any(|t| t.wire_name() == name);
input.set_checked(is_allowed);
}
}
}
}
}
let summary = crate::app::tool_allowlist::summary(&allowed);
dom::swap_inner("tool-allowlist-status", &summary);
} else {
dom::swap_inner("tool-allowlist-status", "all tools enabled");
}
refresh_public_face_status().await;
super::credits::refresh_model_selector().await;
});
}
}
pub(super) async fn refresh_public_face_status() {
let Some(name) = crate::app::tenant::current_name() else { return };
if dom::by_id("public-face-status").is_none() {
return;
}
let face = match crate::app::net::read(crate::app::registry::id_of_name(&name)).await {
Ok(Ok(id)) if id != 0 => crate::app::net::read(crate::app::registry::public_face_of(id))
.await
.ok()
.and_then(Result::ok)
.flatten(),
_ => None,
};
let label: String = match face.as_deref() {
Some("app") => "currently: app · published ✓".into(),
Some("html") => "currently: html · published ✓".into(),
_ => {
let fs = crate::app::shared_opfs();
let has_app = fs.read("app.rl").await.map(|v| !v.is_empty()).unwrap_or(false);
let has_html =
fs.read("index.html").await.map(|v| !v.is_empty()).unwrap_or(false);
if has_app {
"currently: directory · app.rl local only — publish to share".into()
} else if has_html {
"currently: directory · index.html local only — publish to share".into()
} else {
"currently: directory (default)".into()
}
}
};
dom::swap_inner("public-face-status", &label);
}
pub(super) fn header_admin_close() {
dom::swap_outer(
"header-admin-panel",
r#"<div id="header-admin-panel" hidden></div>"#,
);
dom::restore_focus();
}
pub(super) fn show_admin_tab(name: &str) {
let Some(dialog) = dom::by_id("admin-dialog") else { return };
let mut cls: Vec<String> = dialog
.class_name()
.split_whitespace()
.filter(|c| !c.starts_with("tab-"))
.map(String::from)
.collect();
cls.push(format!("tab-{name}"));
dialog.set_class_name(&cls.join(" "));
for tab in ["agent", "account", "usage"] {
let Some(el) = dom::by_id(&format!("admin-tab-btn-{tab}")) else { continue };
let c = el.class_name();
let mut classes: Vec<&str> = c.split_whitespace().filter(|x| *x != "active").collect();
if tab == name {
classes.push("active");
}
el.set_class_name(&classes.join(" "));
}
}