use std::cell::RefCell;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
thread_local! {
static BELL: RefCell<Vec<(String, String)>> = const { RefCell::new(Vec::new()) };
}
const INBOX_FILE: &str = ".lh_notif_inbox.json";
const PENDING_FILE: &str = ".lh_notif_pending.json";
pub(crate) fn push_to_bell(title: &str, body: &str) {
BELL.with(|b| {
let mut v = b.borrow_mut();
v.insert(0, (title.to_string(), body.to_string()));
v.truncate(30);
});
let n = BELL.with(|b| b.borrow().len());
crate::app::dom::swap_outer(
"notif-bell-badge",
&format!("<span id=\"notif-bell-badge\" class=\"notif-badge\">{n}</span>"),
);
let hidden = crate::app::dom::by_id("notif-bell-panel")
.map(|e| e.has_attribute("hidden"))
.unwrap_or(true);
crate::app::dom::swap_outer(
"notif-bell-panel",
&crate::app::templates::notif_list_panel(&bell_items(), None, hidden).into_string(),
);
wasm_bindgen_futures::spawn_local(persist_inbox());
}
pub(crate) fn push_arrived(title: &str, body: &str) {
let title = if title.is_empty() { "localharness" } else { title };
push_to_bell(title, body);
}
async fn persist_inbox() {
let items = bell_items();
let Ok(bytes) = serde_json::to_vec(&items) else { return };
let fs = crate::app::shared_opfs();
if let Err(e) = fs.write_atomic(INBOX_FILE, &bytes).await {
web_sys::console::warn_1(&JsValue::from_str(&format!("notif inbox save: {e}")));
}
}
pub(crate) async fn load_inbox() {
let fs = crate::app::shared_opfs();
let mut items: Vec<(String, String)> = Vec::new();
if let Ok(b) = fs.read(PENDING_FILE).await {
if let Ok(mut v) = serde_json::from_slice::<Vec<(String, String)>>(&b) {
items.append(&mut v);
}
let _ = fs.delete(PENDING_FILE).await;
}
let fresh = items.len();
if let Ok(b) = fs.read(INBOX_FILE).await {
if let Ok(mut v) = serde_json::from_slice::<Vec<(String, String)>>(&b) {
items.append(&mut v);
}
}
if items.is_empty() {
return;
}
items.truncate(30);
BELL.with(|b| *b.borrow_mut() = items);
if fresh > 0 {
crate::app::dom::swap_outer(
"notif-bell-badge",
&format!("<span id=\"notif-bell-badge\" class=\"notif-badge\">{fresh}</span>"),
);
persist_inbox().await; }
crate::app::dom::swap_outer(
"notif-bell-panel",
&crate::app::templates::notif_list_panel(&bell_items(), None, true).into_string(),
);
}
pub(crate) fn bell_items() -> Vec<(String, String)> {
BELL.with(|b| b.borrow().clone())
}
pub(crate) fn clear_bell_badge() {
crate::app::dom::swap_outer(
"notif-bell-badge",
"<span id=\"notif-bell-badge\" class=\"notif-badge\" hidden></span>",
);
}
pub(crate) const VAPID_PUBLIC_KEY: &str =
"BHtamLu5RHqMWbV3JyyEmQKL-lweTVq3ePiFOHGu_EBzvrz4w0SzpWpBTI02UgWOkFR9sbAqPrvj8LOtF5R5jow";
fn js_err(context: &str, e: JsValue) -> String {
format!("{context}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
fn window() -> Result<web_sys::Window, String> {
web_sys::window().ok_or_else(|| "no window".to_string())
}
pub(crate) async fn ensure_permission() -> Result<bool, String> {
use web_sys::{Notification, NotificationPermission};
match Notification::permission() {
NotificationPermission::Granted => return Ok(true),
NotificationPermission::Denied => return Ok(false),
_ => {}
}
let promise = Notification::request_permission().map_err(|e| js_err("requestPermission", e))?;
let result = JsFuture::from(promise)
.await
.map_err(|e| js_err("requestPermission", e))?;
Ok(result.as_string().as_deref() == Some("granted"))
}
async fn sw_registration() -> Option<web_sys::ServiceWorkerRegistration> {
let sw = web_sys::window()?.navigator().service_worker();
let v = JsFuture::from(sw.get_registration()).await.ok()?;
v.dyn_into::<web_sys::ServiceWorkerRegistration>().ok()
}
pub(crate) async fn show(title: &str, body: &str) -> Result<(), String> {
let opts = web_sys::NotificationOptions::new();
opts.set_body(body);
opts.set_icon("/icons/icon-192.png");
opts.set_tag(&format!("lh-{title}-{body}"));
if let Some(reg) = sw_registration().await {
let promise = reg
.show_notification_with_options(title, &opts)
.map_err(|e| js_err("showNotification", e))?;
JsFuture::from(promise)
.await
.map_err(|e| js_err("showNotification", e))?;
return Ok(());
}
web_sys::Notification::new_with_options(title, &opts)
.map(|_| ())
.map_err(|e| js_err("Notification", e))
}
pub(crate) fn vibrate(ms: u32) -> bool {
match web_sys::window() {
Some(win) => win.navigator().vibrate_with_duration(ms),
None => false,
}
}
pub(crate) async fn subscribe_push() -> Result<String, String> {
let container = window()?.navigator().service_worker();
let _ = JsFuture::from(container.register("/sw.js")).await;
let ready = container.ready().map_err(|e| js_err("serviceWorker.ready", e))?;
let reg: web_sys::ServiceWorkerRegistration = JsFuture::from(ready)
.await
.map_err(|e| js_err("serviceWorker.ready", e))?
.dyn_into()
.map_err(|_| "serviceWorker.ready: not a registration".to_string())?;
let manager = reg.push_manager().map_err(|e| js_err("pushManager", e))?;
let opts = web_sys::PushSubscriptionOptionsInit::new();
opts.set_user_visible_only(true);
let key_bytes = {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(VAPID_PUBLIC_KEY)
.map_err(|e| format!("bad VAPID_PUBLIC_KEY constant: {e}"))?
};
let key_js: JsValue = js_sys::Uint8Array::from(key_bytes.as_slice()).into();
opts.set_application_server_key(&key_js);
let sub: web_sys::PushSubscription = JsFuture::from(
manager
.subscribe_with_options(&opts)
.map_err(|e| js_err("push subscribe", e))?,
)
.await
.map_err(|e| js_err("push subscribe", e))?
.dyn_into()
.map_err(|_| "push subscribe: not a PushSubscription".to_string())?;
js_sys::JSON::stringify(&sub)
.map_err(|e| js_err("subscription stringify", e))?
.as_string()
.ok_or_else(|| "subscription stringify: empty".to_string())
}
pub(crate) async fn enable_device_push() -> Result<String, String> {
if !ensure_permission().await? {
return Err("notification permission is blocked — allow notifications for this site in your browser settings, then tap again".to_string());
}
let sub_json = subscribe_push().await?;
let (signer, addr) = crate::app::chat::credit_signer()
.await
.ok_or_else(|| "no identity on this device yet".to_string())?;
let addr_hex = crate::encoding::bytes_to_hex_str(&addr);
let slot = crate::registry::addr_push_sub_of(&addr_hex).await.ok().flatten();
let Some(merged) = crate::registry::merge_push_sub(slot.as_deref(), &sub_json) else {
return Ok("already registered".to_string());
};
let sponsor = crate::app::sponsor::signer().map_err(|e| format!("sponsor: {e}"))?;
let token = crate::registry::ALPHA_USD_ADDRESS;
crate::registry::set_push_sub_sponsored(&signer, &sponsor, merged.as_bytes(), token).await
}
pub(crate) async fn enable_and_publish() -> Result<String, String> {
if !ensure_permission().await? {
return Err("notification permission denied — allow notifications for this site in the browser settings".to_string());
}
let sub_json = subscribe_push().await?;
let (name, owner) = crate::app::tenant::current_tenant_owner().await?;
let token_id = match crate::registry::main_of(&owner).await {
Ok(id) if id != 0 => id,
_ => match crate::registry::id_of_name(&name).await {
Ok(id) if id != 0 => id,
Ok(_) => return Err("this subdomain isn't registered on-chain yet".to_string()),
Err(e) => return Err(format!("id_of_name: {e}")),
},
};
let slot = crate::registry::push_sub_of(token_id).await.ok().flatten();
let Some(merged) = crate::registry::merge_push_sub(slot.as_deref(), &sub_json) else {
return Ok("already registered".to_string());
};
let registry_addr = crate::encoding::parse_address(crate::registry::REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: crate::registry::encode_set_push_sub(token_id, merged.as_bytes()),
};
let gas = crate::app::gas::set_metadata_gas(merged.len());
crate::app::events::run_sponsored_tempo_call(&owner, vec![call], gas, "publish push subscription")
.await
}
pub(crate) async fn refresh_subscription_if_stale() {
if !matches!(
web_sys::Notification::permission(),
web_sys::NotificationPermission::Granted
) {
return;
}
let Ok(current) = subscribe_push().await else {
return;
};
let Ok((name, owner)) = crate::app::tenant::current_tenant_owner().await else {
return;
};
let token_id = match crate::registry::main_of(&owner).await {
Ok(id) if id != 0 => id,
_ => match crate::registry::id_of_name(&name).await {
Ok(id) if id != 0 => id,
_ => return,
},
};
let published = crate::registry::push_sub_of(token_id).await.ok().flatten();
let Some(merged) = crate::registry::merge_push_sub(published.as_deref(), ¤t) else {
return;
};
let publish = async {
let registry_addr = crate::encoding::parse_address(crate::registry::REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: crate::registry::encode_set_push_sub(token_id, merged.as_bytes()),
};
let gas = crate::app::gas::set_metadata_gas(merged.len());
crate::app::events::run_sponsored_tempo_call(
&owner,
vec![call],
gas,
"refresh push subscription",
)
.await
};
match publish.await {
Err(e) => web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"push subscription refresh failed: {e}"
))),
Ok(_) => web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
"stale push subscription refreshed on-chain",
)),
}
}
pub(crate) async fn auto_register_device_push() {
if !matches!(
web_sys::Notification::permission(),
web_sys::NotificationPermission::Granted
) {
return; }
if crate::app::chat::credit_address_existing().await.is_none() {
return;
}
let Some((signer, addr)) = crate::app::chat::credit_signer().await else {
return;
};
let Ok(current) = subscribe_push().await else {
return;
};
let addr_hex = crate::encoding::bytes_to_hex_str(&addr);
let slot = crate::registry::addr_push_sub_of(&addr_hex).await.ok().flatten();
let Some(merged) = crate::registry::merge_push_sub(slot.as_deref(), ¤t) else {
return; };
let Ok(sponsor) = crate::app::sponsor::signer() else {
return;
};
let token = crate::registry::ALPHA_USD_ADDRESS;
match crate::registry::set_push_sub_sponsored(&signer, &sponsor, merged.as_bytes(), token).await
{
Ok(_) => web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
"[push] device auto-registered (address-keyed)",
)),
Err(e) => web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"[push] auto-register failed: {e}"
))),
}
}