UpdatePrompt
Notices a new version of the program, offers a reload at a good moment, without losing input.
Demo
State holds: .
Looked 0 times.
Not pressed.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/update_prompt.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/update_prompt.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/update_prompt.qwe
component UpdatePromptDemoLive() { state latest = "2.4.0" state name = "" state looks = 0 state store = "Not pressed." let check = () => { looks += 1 latest } <div.demo-phone> <div.demo-stack style="padding: 14px; overflow-y: auto; height: 100%; box-sizing: border-box; align-content: start"> <input id="upgp-plain" aria-label="A plain field" placeholder="A plain field" style="min-height: 46px; font-size: 16px; padding: 0 10px"/> <input id="upgp-bound" aria-label="A field in state" placeholder="A field in state" style="min-height: 46px; font-size: 16px; padding: 0 10px" bind:value={name}/> <p.demo-note id="upgp-bound-says">{`State holds: ${name}.`}</p> <textarea name="upgp-notes" id="upgp-notes" aria-label="Notes" placeholder="Notes" style="min-height: 66px; font-size: 16px; padding: 8px 10px"></textarea> <input id="upgp-pass" type="password" aria-label="A password" placeholder="A password" style="min-height: 46px; font-size: 16px; padding: 0 10px"/> <input id="upgp-card" autocomplete="cc-number" aria-label="A card" placeholder="A card" style="min-height: 46px; font-size: 16px; padding: 0 10px"/> <div.demo-row> <Button size="lg" variant="outline" id="upgp-publish" on_click={() => { latest = "2.5.0" }}>Publish a new version</Button> </div> <p.demo-note id="upgp-looks">{`Looked ${looks} times.`}</p> <p.demo-note id="upgp-store">{store}</p> </div> <UpdatePrompt id="upgp-demo" version="2.4.0" check={check} every_ms={300} quiet_ms={500} later_ms={900} on_store={() => { store = "The store was asked for." }}/> <UpdatePrompt id="upgp-own" place="top" title="This page has changed" every_ms={1000} quiet_ms={0}/> </div>}view UpdatePromptDemo() = <UpdatePromptDemoLive/>Source
ui/update_prompt.qwe
// UpdatePrompt: notices that a new version of the program is out, and offers a reload when the// person is not in the middle of something: not while a field has focus, nor just after typing,// nor in a dialog or during a press. What was typed comes back after the reload.//// import ui/update_prompt.qwe use UpdatePrompt//// // With nothing given it fetches its own page now and then, and compares the addresses// // of the page's scripts, which change with the program.// <UpdatePrompt/>//// // Or say what the version is, and how to find the latest. It works from a phone app too.// server fn latest_version() = http.get("https://example.com/version.json").json.version// component Shell(version: str) {// <UpdatePrompt version={version} check={() => latest_version()}/>// }//// version: the version this page is. check(): gives the latest, and may wait; null, false or an// error value says nothing is known, and no one is told. Left out, it reads its own page: pass// `check` for a page whose components differ from one visit to the next, as that changes// the script's address too. every_ms: how often to look (300000), and it also looks on coming// back from the background or online. quiet_ms: how long after the last key it waits (2500).// later_ms: how long Later holds it back (1800000). title, text: what it says. on_store(): in// a phone app, the button to the store: the apps let only http and https out, so a store's// link will not open by itself; say how, or use quilloNative.share. place: "bottom",// "top" or "inline". id, class.//// In a phone app the program comes with the app, so a reload brings nothing new: it says to// update from the store, and has no Reload. It looks only with `check`.//// Reload writes each changed field to sessionStorage, which a reload keeps, even in a phone// app, then puts them back and goes back to the same scroll position. It leaves out passwords,// card numbers and one-time codes (by type and `autocomplete`), file fields, and anything// in `data-no-keep`. Fields are found again by id, then by name, then by place. Should the// new version not have one, what was typed in it is gone. If it cannot be written, it says so// and a second press reloads.//// update_prompt_moment(...) says whether now is a good time, and why not.import ui/theme.qwe use ui_clslet update_prompt_css = css`.ui-upgp-wrap { position: fixed; z-index: 50; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; padding: 12px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); pointer-events: none; }.ui-upgp-wrap[data-place="top"] { top: 0; bottom: auto; padding-top: calc(12px + env(safe-area-inset-top)); padding-bottom: 12px; }.ui-upgp-wrap[data-place="inline"] { position: relative; padding: 0; }.ui-upgp { container-type: inline-size; box-sizing: border-box; width: 100%; max-width: 520px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 15px/1.4 var(--ui-font); pointer-events: auto; animation: ui-upgp-in .2s ease-out; }.ui-upgp-wrap[data-place="inline"] .ui-upgp { box-shadow: none; animation: none; }.ui-upgp-in { display: grid; gap: 10px; padding: 12px; }.ui-upgp-title { margin: 0; font-weight: 600; }.ui-upgp-text { margin: 2px 0 0; color: var(--ui-muted); font-size: 14px; }.ui-upgp-acts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.ui-upgp-btn { min-width: 84px; min-height: 46px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-upgp-go { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.ui-upgp-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-upgp-back { margin: 0; color: var(--ui-muted); font: 14px/1.4 var(--ui-font); }@keyframes ui-upgp-in { from { transform: translateY(12px); opacity: 0; } }@media (hover: hover) { .ui-upgp-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); } .ui-upgp-go:hover { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); } }@media (prefers-reduced-motion: reduce) { .ui-upgp { animation: none; } }@container (min-width: 440px) { .ui-upgp-in { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }}`let update_prompt_slot = "ui-update-prompt"// The scripts a page names: a program's change moves them. Found without a pattern, as the// text is a whole page.fn update_prompt_scripts(html) -> list { if type_of(html) != "str" { return [] } let parts = drop(split(html, "/_quillo/"), 1) | filter((p) => starts_with(p, "b/") || starts_with(p, "r/")) | map((p) => split(p, ".js")[0]) sort(unique(parts | filter((p) => len(p) < 80)))}// Whether a new version is out, from the two pages' scripts.fn update_prompt_page_moved(mine: list, theirs: list) -> bool = len(mine) > 0 && len(theirs) > 0 && mine != theirs// Whether what `check` gave is another version.fn update_prompt_differs(current, latest) -> bool { if latest == null || latest == false || is_err(latest) || type_of(latest) == "map" || type_of(latest) == "list" { return false } let l = trim(str(latest)) l != "" && l != trim(str(current ?? ""))}// Whether now is a good moment: null, or the reason it is not.fn update_prompt_moment(hidden: bool, field: bool, dialog: bool, pressing: bool, since_key: int, quiet_ms: int, held: bool) { if hidden { return "hidden" } if held { return "later" } if field { return "field" } if dialog { return "dialog" } if pressing { return "pressing" } since_key < quiet_ms ? "typing" : null}// Whether an element is one a person types in.fn update_prompt_types_in(tag: str, kind) -> bool = tag == "textarea" || tag == "select" || (tag == "input" && !contains(["button", "submit", "reset", "checkbox", "radio", "range", "color", "file", "image"], str(kind ?? "text")))// Whether a field's value may be written down.fn update_prompt_keeps(kind: str, autocomplete) -> bool { let auto = lower(str(autocomplete ?? "")) !contains(["password", "hidden", "file", "submit", "button", "reset", "image"], kind) && !starts_with(auto, "cc-") && !contains(["current-password", "new-password", "one-time-code"], auto)}// How a field is known after the reload.fn update_prompt_key(id, name, kind: str, value, place: int) -> str { if type_of(id) == "str" && id != "" { return `#${id}${kind == "radio" ? `=${value}` : ""}` } if type_of(name) == "str" && name != "" { return kind == "radio" || kind == "checkbox" ? `@${name}=${value}` : `@${name}` } `%${place}`}// A snapshot still worth using: this page, within two minutes.fn update_prompt_fresh(snap, path: str, now_ms: int) -> bool = type_of(snap) == "map" && snap.path == path && type_of(snap.at) == "int" && now_ms - snap.at < 120000 && type_of(snap.fields) == "map"component UpdatePrompt(version = null, check = null, every_ms: int = 300000, quiet_ms: int = 2500, later_ms: int = 1800000, title: str? = null, text: str? = null, on_store = null, place: str = "bottom", id: str? = null, class: str? = null) { style global update_prompt_css state started = false state app = false state doc = null state mine = [] state latest = null state reason = "waiting" state looking = false state typed_at = 0 state pressing = false state later_until = 0 state warned = false state back = 0 let heading = title ?? (app ? "A new version is out" : "A new version is ready") let words = warned ? "What you typed here could not be kept. Send it first, or press Reload again to lose it." : (text ?? (app ? "Update this app from its store to get it." : "Reload to get it. What you typed stays.")) let ask = () => { js("Promise.resolve", js_new("Promise", (done) => { // An answer that never comes must not stop it looking for good. js("AbortSignal.timeout", 20000).addEventListener("abort", (_e) => { done(err("no answer")) }) js("Promise.resolve", check()).then(done, (_e = null) => { done(err("failed")) }) })) } let read_page = () => { js("Promise.resolve", js_new("Promise", (done) => { let failed = (_e = null) => { done(null) } js("AbortSignal.timeout", 20000).addEventListener("abort", failed) js("fetch", `${js("location.pathname")}${js("location.search")}`, { cache: "no-store", credentials: "same-origin" }).then((res) => { if res.ok { res.text().then(done, failed) } else { done(null) } }, failed) })) } let look = () => { if !looking && latest == null && doc != null && !doc.hidden && js("navigator.onLine") != false { looking = true if check != null { let r = ask() if update_prompt_differs(version, r) { latest = str(r) } } else if !app { let html = read_page() if update_prompt_page_moved(mine, update_prompt_scripts(html)) { latest = "page" } } looking = false } } let judge = () => { let el = doc.activeElement let tag = el != null ? lower(str(el.tagName)) : "" let field = el != null && (el.isContentEditable == true || update_prompt_types_in(tag, el.type)) let dialog = doc.querySelector("dialog[open]") != null reason = update_prompt_moment(doc.hidden, field, dialog, pressing, now() - typed_at, quiet_ms, now() < later_until) } if started { every(every_ms, look) } if latest != null { every(300, judge) } // Writes what changed. True, or an error value when it could not be written. let snapshot = () => { let list = js("Array.from", doc.querySelectorAll("input, textarea, select")) let fields = {} let i = 0 for el in list { let kind = lower(str(el.type ?? "text")) if update_prompt_keeps(kind, el.getAttribute("autocomplete")) && el.closest("[data-no-keep]") == null { let k = update_prompt_key(el.id, el.name, kind, el.value, i) if kind == "checkbox" || kind == "radio" { if el.checked != el.defaultChecked { fields = set(fields, k, el.checked) } } else if kind == "select-one" || kind == "select-multiple" || el.value != el.defaultValue { fields = set(fields, k, el.value) } } i += 1 } let body = json.stringify({ at: now(), path: js("location.pathname"), y: js("window").scrollY, fields }) js("Promise.resolve", js_new("Promise", (done) => { done(js("sessionStorage.setItem", update_prompt_slot, body) ?? true) })) } let restore = () => { let kept = js("Promise.resolve", js_new("Promise", (done) => { let t = js("sessionStorage.getItem", update_prompt_slot) js("sessionStorage.removeItem", update_prompt_slot) done(t) })) if type_of(kept) == "str" { let snap = json.parse(kept) if update_prompt_fresh(snap, js("location.pathname"), now()) { let list = js("Array.from", doc.querySelectorAll("input, textarea, select")) let i = 0 let put = 0 for el in list { let kind = lower(str(el.type ?? "text")) let k = update_prompt_key(el.id, el.name, kind, el.value, i) if has(snap.fields, k) { let v = get(snap.fields, k) // A field in a component not started yet: this wakes it, so the events reach it. el.dispatchEvent(js_new("FocusEvent", "focusin", { bubbles: true })) if type_of(v) == "bool" { el.checked = v } else { el.value = v } el.dispatchEvent(js_new("Event", "input", { bubbles: true })) el.dispatchEvent(js_new("Event", "change", { bubbles: true })) put += 1 } i += 1 } js("window").scrollTo(0, type_of(snap.y) == "int" || type_of(snap.y) == "float" ? snap.y : 0) back = put } } } <div.ui-upgp-wrap data-place={place} @mount={(_el) => { let w = js("window") let d = js("document") let html = d.documentElement.outerHTML doc = d app = w.quilloNative != null mine = update_prompt_scripts(html) restore() let turned = (_e = null) => { if !d.hidden { look() } } let came = (_e = null) => { look() } let keyed = (_e = null) => { typed_at = now() } let down = (_e = null) => { pressing = true } let up = (_e = null) => { pressing = false } d.addEventListener("visibilitychange", turned) w.addEventListener("online", came) d.addEventListener("input", keyed, { passive: true, capture: true }) d.addEventListener("keydown", keyed, { passive: true, capture: true }) d.addEventListener("pointerdown", down, { passive: true, capture: true }) d.addEventListener("pointerup", up, { passive: true, capture: true }) d.addEventListener("pointercancel", up, { passive: true, capture: true }) started = true look() () => { d.removeEventListener("visibilitychange", turned) w.removeEventListener("online", came) d.removeEventListener("input", keyed, { capture: true }) d.removeEventListener("keydown", keyed, { capture: true }) d.removeEventListener("pointerdown", down, { capture: true }) d.removeEventListener("pointerup", up, { capture: true }) d.removeEventListener("pointercancel", up, { capture: true }) } }}> <span.ui-sr role="status">{latest != null && reason == null ? heading : ""}</span> {latest != null && reason == null && <div class={ui_cls(["ui-upgp", class])} id={id}> <div.ui-upgp-in> <div> <p.ui-upgp-title>{heading}</p> <p.ui-upgp-text>{words}</p> </div> <div.ui-upgp-acts> <button.ui-upgp-btn type="button" @click={() => { later_until = now() + later_ms warned = false reason = "later" }}>Later</button> {!app && <button.ui-upgp-btn.ui-upgp-go type="button" @click={() => { let wrote = snapshot() if is_err(wrote) && !warned { warned = true } else { js("location.reload") } }}>Reload</button>} {app && on_store != null && <button.ui-upgp-btn.ui-upgp-go type="button" @click={() => { on_store() }}>Open the store</button>} </div> </div> </div>} </div>}Related
AssetLookupScan or type an asset tag to see its record, history and what to do, with near matches and a report for a tag nobody knows.3 views · 7 downloadsConflictSheetThis device's version against the server's, chosen field by field, explained in plain words.2 views · 6 downloadsDataSaverToggleA setting to keep lighter images and off autoplay, following the phone's save-data, with what it changes.4 views · 5 downloadsDraftKeeperKeeps what was typed on this device through a reload and a crash, offers it back, and clears on send.2 views · 8 downloadsErrorScreenA full screen for failure with the reason, retry, and offline told apart from server trouble.3 views · 9 downloadsForceUpdateCompares versions as numbers, shows a soft or a hard update screen, and the store's link by platform.3 views · 7 downloads
Thread
Thread 0 posts
On UpdatePrompt: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.