Mobile

UnreadMarker

A new-messages line that stays in place, a jump button with a count, and messages marked read as they are seen.

#mobile#interactive#chat#unread#messages#scroll

Demo

Preview
Ana
Morning all.
Ben
Morning. Coffee is on.
You:
Be there in ten.
Ana
The crane is here early.
Ben
Good, we can start on the north side.
Ana
I will move the van.
Ana
Gate is open now.
Ben
Who has the drawings?
You:
They are in my bag.
Ana
Found the second key.
Ben
The first lift is at nine.
Ana
Inspector rang, he will come at eleven.
Ben
Then we should have the fence up by ten.
Ana
Fence is on the next truck.
You:
I will ring the driver.
Ben
Lunch is at the mill today.
Ana
Save me a seat.
Ben
Second lift done.
Ana
Inspector is happy.
Ben
Good work, everyone.
Read:
Ana
Morning all.
Ben
Morning. Coffee is on.
You:
Be there in ten.
Ana
The crane is here early.
Ben
Good, we can start on the north side.
Ana
I will move the van.
Ana
Gate is open now.
Ben
Who has the drawings?
You:
They are in my bag.
Ana
Found the second key.
Ben
The first lift is at nine.
Ana
Inspector rang, he will come at eleven.
Ben
Then we should have the fence up by ten.
Ana
Fence is on the next truck.
Ana
A last_read it cannot find draws no line.
Ana
Nothing read yet: the line is on top.
You:
Yours is never unread.

Get it

It needs theme.qwe, as every component does, and message_bubble.qwe; 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/message_bubble.qwe$ curl -O https://quillolang.org/components/ui/unread_marker.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/unread_marker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/unread_marker.qwe
let unread_marker_demo_rows = [    { id: 1, name: "Ana", text: "Morning all." }, { id: 2, name: "Ben", text: "Morning. Coffee is on." },    { id: 3, name: "You", mine: true, text: "Be there in ten." }, { id: 4, name: "Ana", text: "The crane is here early." },    { id: 5, name: "Ben", text: "Good, we can start on the north side." }, { id: 6, name: "Ana", text: "I will move the van." },    { id: 7, name: "Ana", text: "Gate is open now." }, { id: 8, name: "Ben", text: "Who has the drawings?" },    { id: 9, name: "You", mine: true, text: "They are in my bag." }, { id: 10, name: "Ana", text: "Found the second key." },    { id: 11, name: "Ben", text: "The first lift is at nine." }, { id: 12, name: "Ana", text: "Inspector rang, he will come at eleven." },    { id: 13, name: "Ben", text: "Then we should have the fence up by ten." }, { id: 14, name: "Ana", text: "Fence is on the next truck." },    { id: 15, name: "You", mine: true, text: "I will ring the driver." }, { id: 16, name: "Ben", text: "Lunch is at the mill today." },    { id: 17, name: "Ana", text: "Save me a seat." }, { id: 18, name: "Ben", text: "Second lift done." },    { id: 19, name: "Ana", text: "Inspector is happy." }, { id: 20, name: "Ben", text: "Good work, everyone." }]component UnreadMarkerDemoLive() {    state msgs = unread_marker_demo_rows    state log = []    state left = -1    state next = 21    <div.demo-stack>        <div.demo-phone id="unread-marker-demo" style="height: 420px">            <UnreadMarker messages={msgs} last_read={8} on_read={(ids, n) => { log = log + ids; left = n }}/>        </div>        <div.demo-row>            <Button size="lg" id="unread-marker-arrives" on_click={() => {                msgs = msgs + [{ id: next, name: "Ben", text: `Message ${next}.` }]                next += 1            }}>A message arrives</Button>            <span.demo-note id="unread-marker-log" data-left={left}>Read: {join(log, ",")}</span>        </div>    </div>}component UnreadMarkerDemoQuiet() {    state msgs = slice(unread_marker_demo_rows, 0, 14)    <div.demo-stack>        <div.demo-phone id="unread-marker-quiet" style="height: 200px">            <UnreadMarker messages={msgs} last_read={14}/>        </div>        <Button size="lg" id="unread-marker-quiet-add" on_click={() => { msgs = msgs + [{ id: `q${len(msgs)}`, name: "Ana", text: "Something new." }] }}>Add to the one with nothing unread</Button>    </div>}view UnreadMarkerDemo() =    <div.demo-stack>        <UnreadMarkerDemoLive/>        <UnreadMarkerDemoQuiet/>        <div.demo-row>            <div.demo-phone id="unread-marker-none" style="height: 160px">                <UnreadMarker last_read="an id that is gone" messages={[{ id: "a", name: "Ana", text: "A last_read it cannot find draws no line." }]}/>            </div>            <div.demo-phone id="unread-marker-all" style="height: 160px">                <UnreadMarker messages={[{ id: "constructor", name: "Ana", text: "Nothing read yet: the line is on top." }, { id: "constructor", name: "Ana", text: "Shown once." }, { id: "b", mine: true, text: "Yours is never unread." }]}/>            </div>        </div>    </div>

Source

ui/unread_marker.qwe
// UnreadMarker: a conversation opened where you left it, with a "New messages" line above// the first one you have not read. The line stays where it was as you read and as more// arrive, so that you can find your place again. Each message counts as read once it has// really been on screen, in a page being looked at; a button says how many are left and// goes to the first of them, and then to the latest.////   <UnreadMarker messages={msgs} last_read={me.last_read} on_read={(ids, left) => { save(ids) }}/>//// messages: oldest first, a list of { id, name?, text, time?, mine? }; a message of yours// is never unread. last_read: the id of the last one read: null means none has been, and// an id it cannot find means it cannot say, so no line is drawn and nothing counts as unread.// It is read once, as the line must not move when your own on_read moves it: to start// over, give the component a new `key`.// line_label ("New messages"). label: what screen readers call the list ("Messages").// on_read(ids, left): inside one of your components, the ids newly seen, a batch at a time// about a third of a second after they were, and how many unread are left.// id, class: for the outer element. It fills its parent, which needs a height.// Messages that come later, while you are up the list with nothing unread, start a line// of their own. Without JavaScript the messages and the line are there, and nothing is// marked read.import ui/theme.qwe use ui_clsimport ui/message_bubble.qwe use MessageBubblelet unread_marker_css = css`.ui-unread-marker { position: relative; container-type: inline-size; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; overflow: hidden; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }.ui-unread-marker-scroll { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; overscroll-behavior: contain; }.ui-unread-marker-scroll:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-unread-marker-list { position: relative; box-sizing: border-box; max-width: 720px; margin: 0 auto; padding: 8px 12px 64px; }.ui-unread-marker-row { position: relative; padding: 3px 0; }.ui-unread-marker-fresh::before { content: ""; position: absolute; top: 50%; left: -6px; width: 6px; height: 6px; margin-top: -3px; border-radius: 50%; background: var(--ui-accent); }.ui-unread-marker-line { display: flex; align-items: center; gap: 10px; margin: 10px 0 6px; color: var(--ui-danger); font-size: 12px; font-weight: 700; line-height: 18px; }.ui-unread-marker-line::before, .ui-unread-marker-line::after { content: ""; flex: 1; height: 1px; background: currentColor; opacity: .55; }.ui-unread-marker-jump {  position: absolute; right: 12px; bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 3; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; min-height: 44px;  padding: 0 14px; border: 0; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); box-shadow: var(--ui-shadow); font: 600 14px var(--ui-font); cursor: pointer;}.ui-unread-marker-jump svg { width: 18px; height: 18px; transition: transform .15s; }.ui-unread-marker-jump[data-way="up"] svg { transform: rotate(180deg); }.ui-unread-marker-jump:focus-visible { outline: none; box-shadow: var(--ui-focus); }@container (min-width: 640px) { .ui-unread-marker-jump { right: 24px; } }@media (prefers-reduced-motion: reduce) { .ui-unread-marker-jump svg { transition: none; } }`// The messages that can be shown, each once, with its id as text.fn unread_marker_clean(messages) {    let kept = (type_of(messages) == "list" ? messages : []) | filter((m) => type_of(m) == "map" && m.id != null)    let seq = for i in range(len(kept)) => { ...kept[i], seq: i, uid: str(kept[i].id) }    let by_id = group_by(seq, (m) => m.uid)    seq | filter((m) => get(by_id, m.uid, [m])[0].seq == m.seq)}// Where unread messages begin: the place in the list after `last_read`, the start when// nothing has been read, and the end, so none, when last_read cannot be found.fn unread_marker_start(rows: list, last_read) -> int {    if last_read == null { return 0 }    let at = index_of(rows | map((r) => r.uid), str(last_read))    at == null ? len(rows) : at + 1}// The id above which the line goes, or null when nothing is unread: the first message// from someone else at or after `start`.fn unread_marker_line(rows: list, start: int) {    let hit = find(slice(rows, start, len(rows)), (r) => r.mine != true)    hit != null ? hit.uid : null}// The ids still unread, oldest first.fn unread_marker_left(rows: list, line, seen) {    if line == null { return [] }    let ids = rows | map((r) => r.uid)    let from = index_of(ids, line)    if from == null { return [] }    slice(rows, from, len(rows)) | filter((r) => r.mine != true && !get(seen, r.uid, false)) | map((r) => r.uid)}// Whether a message has been seen: most of it in sight, or, for one too tall for that,// as much of it as the list can show.fn unread_marker_seen(ratio: num, height: num, room: num) -> bool {    if ratio >= 0.6 { return true }    height > 0 && room > 0 && ratio * height >= room * 0.6}// What the button says: "3 new", and 99+ at most.fn unread_marker_label(left: int) -> str = left > 99 ? "99+ new" : `${left} new`component UnreadMarker(messages: list = [], last_read = null, line_label: str = "New messages", label: str = "Messages",                       on_read = null, id: str? = null, class: str? = null) {    style global unread_marker_css    state start_rows = unread_marker_clean(messages)    state line = unread_marker_line(start_rows, unread_marker_start(start_rows, last_read))    state seen = {}    state fresh = []    state box = null    state list = null    state watcher = null    state low = true        // at the bottom    state known = len(start_rows)    state way = "down"    let rows = unread_marker_clean(messages)    let left = unread_marker_left(rows, line, seen)    let count = len(left)    let flush = () => {        let ids = fresh        fresh = []        on_read && on_read(ids, count)    }    if len(fresh) > 0 { every(320, flush) }    let row_of = (uid) => find(js("Array.from", list.querySelectorAll("[data-uid]")), (r) => r.dataset.uid == uid)    let reckon = () => {        low = box.scrollHeight - box.clientHeight - box.scrollTop < 40        if count > 0 {            let r = row_of(left[0])            if r != null { way = r.getBoundingClientRect().bottom < box.getBoundingClientRect().top ? "up" : "down" }        }    }    let watch = () => {        for r in js("Array.from", list.querySelectorAll("[data-unread]")) { watcher.observe(r) }    }    let read = (entries) => {        if js("document.visibilityState") == "visible" {            let room = box.clientHeight            let ids = entries | filter((e) => e.isIntersecting && unread_marker_seen(e.intersectionRatio, e.boundingClientRect.height, room)) | map((e) => e.target.dataset.uid)            let more = ids | filter((u) => !get(seen, u, false))            if len(more) > 0 {                seen = reduce(more, seen, (m, u) => set(m, u, true))                fresh = fresh + more                for e in entries { if contains(more, e.target.dataset.uid) { watcher.unobserve(e.target) } }            }        }    }    let go = (place: num) => {        let still = js("matchMedia", "(prefers-reduced-motion: reduce)").matches        box.scrollTo({ top: place, behavior: still ? "auto" : "smooth" })    }    <div class={ui_cls(["ui-unread-marker", class])} id={id}         @mount={(el) => {             let d = js("document")             let back = (_e = null) => {                 if d.visibilityState == "visible" {                     // Observing again reports what is in sight now.                     watcher.disconnect()                     watch()                 }             }             d.addEventListener("visibilitychange", back)             () => { d.removeEventListener("visibilitychange", back) }         }}>        <div.ui-unread-marker-scroll role="log" aria-label={label} tabindex="0"             @mount={(el) => { box = el }}             @scroll={reckon}>            <div.ui-unread-marker-list                 @mount={(el) => {                     list = el                     watcher = js_new("IntersectionObserver", read, { root: box, threshold: [0, 0.3, 0.6, 1] })                     let mark = el.querySelector("[role=separator]")                     box.scrollTop = mark != null ? mark.offsetTop - 12 : box.scrollHeight                     watch()                     reckon()                     let was = el.offsetHeight                     let sized = js_new("ResizeObserver", (_r = null) => {                         // Messages arrived. At the bottom, stay there; elsewhere nothing need move.                         let grew = el.offsetHeight > was                         was = el.offsetHeight                         if grew && low { box.scrollTop = box.scrollHeight }                         // With no line yet, what came while you were away from the bottom starts one.                         if line == null && !low { line = unread_marker_line(rows, known) }                         known = len(rows)                         watch()                         reckon()                     })                     sized.observe(el)                     () => {                         sized.disconnect()                         watcher.disconnect()                     }                 }}>                {for r in rows => <div key={r.uid}>                    {r.uid == line && <div.ui-unread-marker-line role="separator" aria-label={line_label}>{line_label}</div>}                    <div class={ui_cls(["ui-unread-marker-row", contains(left, r.uid) && "ui-unread-marker-fresh"])} data-uid={r.uid}                         data-unread={contains(left, r.uid) ? "true" : null}>                        <MessageBubble mine={r.mine == true} name={r.name} time={r.time}>{str(r.text ?? "")}</MessageBubble>                    </div>                </div>}            </div>        </div>        {(count > 0 || !low) && <button.ui-unread-marker-jump type="button" data-way={count > 0 ? way : "down"}                aria-label={count > 0 ? `${unread_marker_label(count)}, go to the first` : "Go to the latest"}                @click={() => {                    let r = count > 0 ? row_of(left[0]) : null                    go(r != null ? r.offsetTop - 12 : box.scrollHeight)                }}>            {count > 0 && <span>{unread_marker_label(count)}</span>}            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M6 13l6 6 6-6"/></svg>        </button>}    </div>}
Thread
25Useful? Like it, or add to its thread below.

Thread 0 posts

On UnreadMarker: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.