clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinyclient / chat-activity.js
chat-activity.js 7.3K
1(() => {2 "use strict";3 const tiny=globalThis.tiny;4 class ChatActivity extends HTMLElement {5 connectedCallback() {6 if (this.running) return;7 this.running=true; this.stopped=false; this.version="";8 this.onEvent=e=>{ if (e.detail?.room===this.getAttribute("room")) this.schedule(); };9 this.onVisibility=()=>{ if (!document.hidden) this.refresh(); };10 this.onLogout=()=>{ this.stopped=true; this.abort?.(); this.replaceChildren(); };11 this.onConfig=e=>{12 const cfg=e.detail?.cfg;13 if(e.target!==this || cfg?.trigger?.target!==this) return;14 const force=this.forceRefresh;15 cfg.credentials="same-origin"; cfg.cache="no-store"; cfg.transition=false;16 cfg.headers={...cfg.headers,Accept:"text/html"};17 this.abort=cfg.abort;18 cfg.swap=()=>this.swap(cfg,force);19 };20 this.onFinally=e=>{ if(e.target===this) { this.loading=false; this.abort=null; queueMicrotask(()=>{this.resolveRefresh?.();this.resolveRefresh=null;}); } };21 this.onError=e=>{ if(e.target===this && e.detail?.error?.name!=="AbortError") this.setAttribute("title","Chat activity could not be refreshed."); };22 document.addEventListener("tiny:room-event",this.onEvent);23 document.addEventListener("visibilitychange",this.onVisibility);24 document.addEventListener("tiny:logout",this.onLogout);25 this.addEventListener("fx:config",this.onConfig);26 this.addEventListener("fx:finally",this.onFinally);27 this.addEventListener("fx:error",this.onError);28 this.timer=setInterval(()=>this.refresh(),15000);29 queueMicrotask(()=>{if(this.isConnected && !this.stopped){this.dispatchEvent(new CustomEvent("fx:process",{bubbles:true}));this.refresh();}});30 }31 disconnectedCallback() {32 this.running=false;this.stopped=true;this.abort?.();clearInterval(this.timer);clearTimeout(this.debounce);33 document.removeEventListener("tiny:room-event",this.onEvent);document.removeEventListener("visibilitychange",this.onVisibility);document.removeEventListener("tiny:logout",this.onLogout);34 this.removeEventListener("fx:config",this.onConfig);this.removeEventListener("fx:finally",this.onFinally);this.removeEventListener("fx:error",this.onError);35 this.resolveRefresh?.();this.resolveRefresh=null;this.loading=false;36 }37 schedule() { if(!this.debounce)this.debounce=setTimeout(()=>{this.debounce=null;this.refresh();},400); }38 async read() {39 const url=new URL(tiny.localPath(this.getAttribute("endpoint")),location.href);40 if(url.origin!==location.origin)throw Error("Invalid activity address.");41 const response=await fetch(url,{credentials:"same-origin",cache:"no-store"});42 if(!response.ok)throw Error("Chat activity could not be refreshed.");43 return response.text();44 }45 async refresh(force=false) {46 if(this.loading || this.stopped || document.hidden || (!force && this.contains(document.activeElement)) || !this.__fixi)return;47 this.loading=true;this.forceRefresh=force;48 return new Promise(resolve=>{this.resolveRefresh=resolve;this.dispatchEvent(new CustomEvent("tiny:activity-refresh"));});49 }50 swap(cfg,force=false) {51 if(this.stopped || !this.isConnected || !cfg.response.ok || cfg.text===this.version || (!force && this.contains(document.activeElement)))return;52 const opened=new Set([...this.querySelectorAll("details[open][data-task]")].map(el=>el.dataset.task));53 morph(cfg.target,cfg.text);54 cfg.target.querySelectorAll("details[data-task]").forEach(el=>{el.open=opened.has(el.dataset.task);});55 cfg.target.querySelectorAll("chat-decision").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}});56 this.version=cfg.text;57 }58 }59 class ChatDecision extends tiny.ui.FormElement {60 connectedCallback() { if(this.form) return; super.connectedCallback(); }61 async submit(form) {62 const host=this.closest("chat-activity"),id=this.getAttribute("event"),author=this.getAttribute("pubkey"),actor=this.getAttribute("actor"),room=this.getAttribute("room");63 const signer=tiny.signer();64 if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account.");65 const fresh=document.createElement("template"); fresh.innerHTML=await host.read();66 const allowed=[...fresh.content.querySelectorAll("chat-decision")].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor && el.getAttribute("pubkey")===author);67 if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); }68 let unsigned;69 if(this.hasAttribute("native")) {70 const selection=allowed.getAttribute("selection"),interaction=allowed.getAttribute("interaction");71 if(!allowed.hasAttribute("native") || !["single","multiple","text"].includes(selection)) throw Error("This request changed. Refresh and try again.");72 const field=form.elements.option;73 const fields=field ? (field.length === undefined ? [field] : [...field]) : [];74 const selected=fields.filter(input=>input.checked).map(input=>input.value);75 const custom=(form.elements["custom-answer"]?.value || "").trim();76 if(custom && (interaction !== "question" || !allowed.hasAttribute("freeform"))) throw Error("This request accepts options only.");77 const freshOptions=[...(allowed.querySelectorAll?.('input[name="option"]') || [])].map(input=>input.value);78 if(selected.some(value=>!freshOptions.includes(value))) { host.schedule(); throw Error("This request's options changed."); }79 if(!selected.length && !custom) throw Error("Choose an option or write an answer.");80 if(custom && selected.length) throw Error("Choose options or write your own answer, not both.");81 if(!custom && (selection === "text" || (selection === "single" && selected.length !== 1))) throw Error("Choose one option.");82 if(custom && custom.length > 8000) throw Error("Your answer is too long.");83 const content=custom ? (selection === "text" ? custom : JSON.stringify({text:custom})) : (selection === "multiple" ? JSON.stringify(selected) : selected[0]);84 unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};85 } else if(this.hasAttribute("question")) {86 const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first.");87 unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};88 } else {89 const choice=this.submitter?.value;90 if(!["+","-"].includes(choice)) throw Error("Choose Approve or Deny.");91 unsigned={kind:7,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["e",id,"",author],["p",author],["k",this.getAttribute("kind")]],content:choice};92 }93 this.report("Signing…"); await tiny.signing.publish(unsigned);94 this.report("Answer sent."); this.querySelectorAll("button,input,textarea").forEach(el=>el.disabled=true);95 await host.refresh(true);96 }97 }98 customElements.define("chat-activity",ChatActivity); customElements.define("chat-decision",ChatDecision);99})();