1(() => {2 "use strict";3 const tiny=globalThis.tiny;4 const isHex64=value=>typeof value==="string" && /^[0-9a-f]{64}$/.test(value);5 const now=()=>Math.floor(Date.now()/1000);6 // requireSigner confirms the connected signer still belongs to the account7 // the card or form was rendered for before anything is signed.8 const requireSigner=async actor=>{9 const signer=tiny.signer();10 if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account.");11 };12 // freshElement re-reads the panel and returns the matching element when13 // the request still waits for this account, so a stale card cannot sign.14 const freshElement=async(host,name,id,actor)=>{15 const fresh=document.createElement("template"); fresh.innerHTML=await host.read();16 return [...fresh.content.querySelectorAll(name)].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor) || null;17 };18 class ChatActivity extends HTMLElement {19 connectedCallback() {20 if (this.running) return;21 this.running=true; this.stopped=false; this.version="";22 this.onEvent=e=>{ if (e.detail?.room===this.getAttribute("room")) this.schedule(); };23 this.onVisibility=()=>{ if (!document.hidden) this.refresh(); };24 this.onLogout=()=>{ this.stopped=true; this.abort?.(); this.replaceChildren(); };25 this.onConfig=e=>{26 const cfg=e.detail?.cfg;27 if(e.target!==this || cfg?.trigger?.target!==this) return;28 const force=this.forceRefresh;29 cfg.credentials="same-origin"; cfg.cache="no-store"; cfg.transition=false;30 cfg.headers={...cfg.headers,Accept:"text/html"};31 this.abort=cfg.abort;32 cfg.swap=()=>this.swap(cfg,force);33 };34 this.onFinally=e=>{ if(e.target===this) { this.loading=false; this.abort=null; queueMicrotask(()=>{this.resolveRefresh?.();this.resolveRefresh=null;}); } };35 this.onError=e=>{ if(e.target===this && e.detail?.error?.name!=="AbortError") this.setAttribute("title","Chat activity could not be refreshed."); };36 document.addEventListener("tiny:room-event",this.onEvent);37 document.addEventListener("visibilitychange",this.onVisibility);38 document.addEventListener("tiny:logout",this.onLogout);39 this.addEventListener("fx:config",this.onConfig);40 this.addEventListener("fx:finally",this.onFinally);41 this.addEventListener("fx:error",this.onError);42 this.timer=setInterval(()=>this.refresh(),15000);43 queueMicrotask(()=>{if(this.isConnected && !this.stopped){this.dispatchEvent(new CustomEvent("fx:process",{bubbles:true}));this.refresh();}});44 }45 disconnectedCallback() {46 this.running=false;this.stopped=true;this.abort?.();clearInterval(this.timer);clearTimeout(this.debounce);47 document.removeEventListener("tiny:room-event",this.onEvent);document.removeEventListener("visibilitychange",this.onVisibility);document.removeEventListener("tiny:logout",this.onLogout);48 this.removeEventListener("fx:config",this.onConfig);this.removeEventListener("fx:finally",this.onFinally);this.removeEventListener("fx:error",this.onError);49 this.resolveRefresh?.();this.resolveRefresh=null;this.loading=false;50 }51 schedule() { if(!this.debounce)this.debounce=setTimeout(()=>{this.debounce=null;this.refresh();},400); }52 async read() {53 const url=new URL(tiny.localPath(this.getAttribute("endpoint")),location.href);54 if(url.origin!==location.origin)throw Error("Invalid activity address.");55 const response=await fetch(url,{credentials:"same-origin",cache:"no-store"});56 if(!response.ok)throw Error("Chat activity could not be refreshed.");57 return response.text();58 }59 async refresh(force=false) {60 if(this.loading || this.stopped || document.hidden || (!force && this.contains(document.activeElement)) || !this.__fixi)return;61 this.loading=true;this.forceRefresh=force;62 return new Promise(resolve=>{this.resolveRefresh=resolve;this.dispatchEvent(new CustomEvent("tiny:activity-refresh"));});63 }64 swap(cfg,force=false) {65 if(this.stopped || !this.isConnected || !cfg.response.ok || cfg.text===this.version || (!force && this.contains(document.activeElement)))return;66 const opened=new Set([...this.querySelectorAll("details[open][data-task]")].map(el=>el.dataset.task));67 morph(cfg.target,cfg.text);68 cfg.target.querySelectorAll("details[data-task]").forEach(el=>{el.open=opened.has(el.dataset.task);});69 cfg.target.querySelectorAll("chat-decision,chat-cancel").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}});70 this.version=cfg.text;71 }72 }73 class ChatDecision extends tiny.ui.FormElement {74 connectedCallback() { if(this.form) return; super.connectedCallback(); }75 async submit(form) {76 const host=this.closest("chat-activity"),id=this.getAttribute("event"),author=this.getAttribute("pubkey"),actor=this.getAttribute("actor"),room=this.getAttribute("room");77 await requireSigner(actor);78 const current=await freshElement(host,"chat-decision",id,actor);79 const allowed=current && current.getAttribute("pubkey")===author ? current : null;80 if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); }81 let unsigned;82 if(this.hasAttribute("native")) {83 const selection=allowed.getAttribute("selection"),interaction=allowed.getAttribute("interaction");84 if(!allowed.hasAttribute("native") || !["single","multiple","text"].includes(selection)) throw Error("This request changed. Refresh and try again.");85 const field=form.elements.option;86 const fields=field ? (field.length === undefined ? [field] : [...field]) : [];87 const selected=fields.filter(input=>input.checked).map(input=>input.value);88 const custom=(form.elements["custom-answer"]?.value || "").trim();89 if(custom && (interaction !== "question" || !allowed.hasAttribute("freeform"))) throw Error("This request accepts options only.");90 const freshOptions=[...(allowed.querySelectorAll?.('input[name="option"]') || [])].map(input=>input.value);91 if(selected.some(value=>!freshOptions.includes(value))) { host.schedule(); throw Error("This request's options changed."); }92 if(!selected.length && !custom) throw Error("Choose an option or write an answer.");93 if(selection === "single" && selected.length > 1) throw Error("Choose one option.");94 if(!custom && (selection === "text" || (selection === "single" && selected.length !== 1))) throw Error("Choose one option.");95 if(custom && custom.length > 8000) throw Error("Your answer is too long.");96 const content=custom ? (selection === "text" ? custom : JSON.stringify(selected.length ? {choices:selected,text:custom} : {text:custom})) : (selection === "multiple" ? JSON.stringify(selected) : selected[0]);97 if(content.length > 8000) throw Error("Your answer is too long.");98 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};99 } else if(this.hasAttribute("question")) {100 const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first.");101 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};102 } else {103 const choice=this.submitter?.value;104 if(!["+","-"].includes(choice)) throw Error("Choose Approve or Deny.");105 unsigned={kind:7,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["e",id,"",author],["p",author],["k",this.getAttribute("kind")]],content:choice};106 }107 this.report("Signing…"); await tiny.signing.publish(unsigned);108 this.report("Answer sent."); this.querySelectorAll("button,input,textarea").forEach(el=>el.disabled=true);109 await host.refresh(true);110 }111 }112 // ChatRequest publishes a kind 43001 job request for the room, and for113 // the thread when the panel is scoped to one.114 class ChatRequest extends tiny.ui.FormElement {115 connectedCallback() { if(this.form) return; super.connectedCallback(); }116 async submit(form) {117 const host=this.closest("chat-activity"),actor=this.getAttribute("actor"),room=this.getAttribute("room"),root=this.getAttribute("root");118 const agent=(form.elements.agent?.value || "").trim().toLowerCase();119 const subject=(form.elements.subject?.value || "").trim(), content=(form.elements.content?.value || "").trim();120 if(!isHex64(agent)) throw Error("Choose an agent or enter its 64-character public key.");121 if(!content && !subject) throw Error("Write the task first.");122 if(subject.length > 200) throw Error("The subject is too long.");123 if(content.length > 8000) throw Error("The task is too long.");124 await requireSigner(actor);125 const tags=[["h",room],["p",agent]];126 if(subject) tags.push(["subject",subject]);127 if(root) { if(!isHex64(root)) throw Error("The thread address is missing."); tags.push(["e",root,"","root"]); }128 this.report("Signing…"); await tiny.signing.publish({kind:43001,created_at:now(),tags,content});129 form.reset?.(); this.report("Task sent.");130 await host?.refresh(true);131 }132 }133 // ChatCancel publishes a kind 43005 cancel for an open task the account134 // requested, naming each key the request asked.135 class ChatCancel extends tiny.ui.FormElement {136 connectedCallback() { if(this.form) return; super.connectedCallback(); }137 async submit() {138 const host=this.closest("chat-activity"),id=this.getAttribute("event"),actor=this.getAttribute("actor"),room=this.getAttribute("room");139 await requireSigner(actor);140 const allowed=await freshElement(host,"chat-cancel",id,actor);141 if(!allowed) { host.schedule(); throw Error("This task is no longer open."); }142 const tags=[["e",id],["h",room]];143 for(const assignee of (allowed.getAttribute("assignees") || "").split(/\s+/)) if(isHex64(assignee)) tags.push(["p",assignee]);144 this.report("Signing…"); await tiny.signing.publish({kind:43005,created_at:now(),tags,content:""});145 this.report("Task cancelled."); this.querySelectorAll("button").forEach(el=>el.disabled=true);146 await host.refresh(true);147 }148 }149 customElements.define("chat-activity",ChatActivity); customElements.define("chat-decision",ChatDecision);150 customElements.define("chat-request",ChatRequest); customElements.define("chat-cancel",ChatCancel);151})();