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";
const MSG_CURSOR_FILE: &str = ".lh_msg_cursor";
const DEV_ID_FILE: &str = ".lh_dev_id";
const LH_BALANCE_MARK_FILE: &str = ".lh_balance_mark";
pub(crate) async fn device_id() -> String {
let fs = crate::app::shared_opfs();
if let Ok(b) = fs.read(DEV_ID_FILE).await {
if let Ok(s) = String::from_utf8(b) {
let t = s.trim();
if !t.is_empty() {
return t.to_string();
}
}
}
let id = uuid::Uuid::new_v4().to_string();
let _ = fs.write_atomic(DEV_ID_FILE, id.as_bytes()).await;
id
}
async fn tag_sub_with_dev(sub_json: &str) -> String {
let dev = device_id().await;
match serde_json::from_str::<serde_json::Value>(sub_json) {
Ok(serde_json::Value::Object(mut map)) => {
map.insert("dev".to_string(), serde_json::Value::String(dev));
serde_json::Value::Object(map).to_string()
}
_ => sub_json.to_string(),
}
}
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);
});
crate::app::dom::swap_outer(
"notif-bell-badge",
"<span id=\"notif-bell-badge\" class=\"notif-badge\"></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, false).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);
}
pub(crate) async fn stash_to_inbox(title: &str, body: &str) {
let fs = crate::app::shared_opfs();
let mut items: Vec<(String, String)> = match fs.read(PENDING_FILE).await {
Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
Err(_) => Vec::new(),
};
items.insert(0, (title.to_string(), body.to_string()));
items.truncate(30);
let Ok(bytes) = serde_json::to_vec(&items) else { return };
if let Err(e) = fs.write_atomic(PENDING_FILE, &bytes).await {
web_sys::console::warn_1(&JsValue::from_str(&format!("notif stash: {e}")));
}
}
const RELEASES_URL: &str = "https://github.com/compusophy/localharness/releases";
const FEEDBACK_RESOLUTIONS_URL: &str = "https://localharness.xyz/feedback-resolutions.json";
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
pub(crate) fn notify_version_change() {
let Some(storage) = local_storage() else { return };
let current = crate::app::templates::APP_VERSION;
let seen = storage.get_item("lh_seen_version").ok().flatten();
if seen.as_deref() == Some(current) {
return;
}
let _ = storage.set_item("lh_seen_version", current);
if seen.is_some() {
push_to_bell(
&format!("localharness v{current} is live"),
&format!("A new version shipped — see what changed: {RELEASES_URL}/tag/v{current}"),
);
}
}
#[derive(serde::Deserialize)]
struct Resolution {
index: u32,
sender: String,
#[serde(default)]
version: String,
#[serde(default)]
preview: String,
}
pub(crate) async fn notify_resolved_feedback() {
let mut mine: Vec<String> = Vec::new();
crate::app::APP.with(|c| {
let app = c.borrow();
if let crate::app::VerifyState::Verified { address } = &app.verify_state {
mine.push(address.to_lowercase());
}
if let Some(w) = &app.wallet {
mine.push(w.address_hex().to_lowercase());
}
});
if mine.is_empty() {
return;
}
let fetched = crate::app::net::read(async {
let resp = reqwest::Client::new()
.get(FEEDBACK_RESOLUTIONS_URL)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.text().await.ok()
})
.await
.ok()
.flatten();
let Some(text) = fetched else { return };
let Ok(items) = serde_json::from_str::<Vec<Resolution>>(&text) else { return };
let Some(storage) = local_storage() else { return };
let seen_existing = storage.get_item("lh_seen_resolutions").ok().flatten();
let first_run = seen_existing.is_none();
let mut seen: std::collections::HashSet<u32> = seen_existing
.unwrap_or_default()
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
let mut changed = false;
for r in &items {
if !mine.contains(&r.sender.to_lowercase()) {
continue;
}
if !seen.insert(r.index) {
continue; }
changed = true;
if first_run {
continue; }
let preview = if r.preview.is_empty() {
String::new()
} else {
format!("\u{201c}{}\u{201d} — ", r.preview)
};
let ver = if r.version.is_empty() { "a recent update" } else { &r.version };
push_to_bell(
"Your feedback was resolved",
&format!("{preview}addressed in {ver}. Changelog: {RELEASES_URL}"),
);
}
if changed || first_run {
let joined: Vec<String> = seen.iter().map(u32::to_string).collect();
let _ = storage.set_item("lh_seen_resolutions", &joined.join(","));
}
}
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;
}
{
let mut seen = std::collections::HashSet::new();
items.retain(|e| seen.insert(e.clone()));
}
items.truncate(30);
BELL.with(|b| *b.borrow_mut() = items);
if fresh > 0 {
crate::app::dom::swap_outer(
"notif-bell-badge",
"<span id=\"notif-bell-badge\" class=\"notif-badge\"></span>",
);
persist_inbox().await; }
crate::app::dom::swap_outer(
"notif-bell-panel",
&crate::app::templates::notif_list_panel(&bell_items(), None, true, false).into_string(),
);
}
pub(crate) async fn import_onchain_messages() {
let Some(name) = crate::app::tenant::current_name() else {
return;
};
let token_id = match crate::registry::id_of_name(&name).await {
Ok(id) if id != 0 => id,
_ => return,
};
let count = match crate::registry::inbox_count(token_id).await {
Ok(c) => c,
Err(_) => return,
};
let fs = crate::app::shared_opfs();
let cursor: u64 = fs
.read(MSG_CURSOR_FILE)
.await
.ok()
.and_then(|b| String::from_utf8(b).ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
if count <= cursor {
return;
}
let existing = bell_items();
for i in cursor..count {
if let Ok((from, _ts, raw)) = crate::registry::message_at(token_id, i).await {
let (title, body) = parse_note(raw.trim(), &from);
if title.is_empty() && body.is_empty() {
continue;
}
if existing.iter().any(|(t, b)| *t == title && *b == body) {
continue; }
push_to_bell(&title, &body);
}
}
let _ = fs.write_atomic(MSG_CURSOR_FILE, count.to_string().as_bytes()).await;
}
pub(crate) async fn notify_received_lh() {
let Some(name) = crate::app::tenant::current_name() else {
return;
};
let Ok((_, owner)) = crate::app::tenant::current_tenant_owner().await else {
return;
};
let wallet = crate::registry::token_balance_of(&owner).await.unwrap_or(0);
let tba = match crate::registry::tba_of_name(&name).await.ok().flatten() {
Some(addr) => crate::registry::token_balance_of(&addr).await.unwrap_or(0),
None => 0,
};
let total = wallet.saturating_add(tba);
let fs = crate::app::shared_opfs();
let mark: u128 = fs
.read(LH_BALANCE_MARK_FILE)
.await
.ok()
.and_then(|b| String::from_utf8(b).ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let first_run = fs.read(LH_BALANCE_MARK_FILE).await.is_err();
if total != mark || first_run {
let _ = fs
.write_atomic(LH_BALANCE_MARK_FILE, total.to_string().as_bytes())
.await;
}
if first_run || total <= mark {
return;
}
let delta = total - mark;
let amount = crate::app::format_wei_as_test_eth(delta);
let title = format!("+{amount} $LH received");
let body = "incoming $LH transfer — check your wallet".to_string();
let already = bell_items()
.iter()
.any(|(t, b)| (*t == title || t.ends_with(&title)) && *b == body);
if !already {
push_to_bell(&title, &body);
}
}
fn parse_note(raw: &str, from: &str) -> (String, String) {
if raw.is_empty() {
return (String::new(), String::new());
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
let t = v.get("title").and_then(|x| x.as_str()).unwrap_or("");
let b = v.get("body").and_then(|x| x.as_str()).unwrap_or("");
if !t.is_empty() || !b.is_empty() {
let short = from.get(..8).unwrap_or(from);
let title = if t.is_empty() {
format!("message · {short}…")
} else {
t.to_string()
};
return (title, b.to_string());
}
}
let short = from.get(..8).unwrap_or(from);
(format!("message · {short}…"), raw.to_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) fn clear_all() {
BELL.with(|b| b.borrow_mut().clear());
clear_bell_badge();
crate::app::dom::swap_outer(
"notif-bell-panel",
&crate::app::templates::notif_list_panel(&[], None, false, false).into_string(),
);
wasm_bindgen_futures::spawn_local(persist_inbox());
}
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 = tag_sub_with_dev(&subscribe_push().await?).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 = tag_sub_with_dev(&subscribe_push().await?).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 current = tag_sub_with_dev(¤t).await;
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 current = tag_sub_with_dev(¤t).await;
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}"
))),
}
}