Data

ChatThread

A conversation: grouped messages with avatars, days, a typing indicator, and a box where Enter sends.

#chat#social#list#no-js

Demo

Preview
  1. Yesterday
    Ana Silva
    Hi! Did the new build go out?
  2. You:
    Not yet: one test is still failing on Windows.
  3. Ana Silva
    Ok, no rush.
  4. Today
    Ana Silva
    Morning! Any luck with that test?
  5. Ana Silva:
    I can pair on it after lunch if that helps.
  6. You:
    Fixed it: a path with a space in it.
  7. You:
    Shipping now 🚀
  8. Ben Okafor
    Nice one. I'll tell the customers.

Enter sends, Shift+Enter starts a new line; Ana answers. Below, a thread whose box posts a form:

  1. Monday
    Support
    Thanks for writing in. Could you send us your order number?
  2. You:
    It's #1042.

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/chat_thread.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/chat_thread.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/chat_thread.qwe
let chat_thread_demo_start = [    { id: 1, from: "Ana Silva", text: "Hi! Did the new build go out?", time: "Yesterday 17:02", day: "Yesterday" },    { id: 2, from: "Me", mine: true, text: "Not yet: one test is still failing on Windows.", time: "Yesterday 17:05", day: "Yesterday" },    { id: 3, from: "Ana Silva", text: "Ok, no rush.", time: "Yesterday 17:06", day: "Yesterday" },    { id: 4, from: "Ana Silva", text: "Morning! Any luck with that test?", time: "09:12", day: "Today" },    { id: 5, from: "Ana Silva", text: "I can pair on it after lunch\nif that helps.", time: "09:13", day: "Today" },    { id: 6, from: "Me", mine: true, text: "Fixed it: a path with a space in it.", time: "09:30", day: "Today" },    { id: 7, from: "Me", mine: true, text: "Shipping now 🚀", time: "09:30", day: "Today" },    { id: 8, from: "Ben Okafor", text: "Nice one. I'll tell the customers.", time: "09:41", day: "Today" }]component ChatThreadDemoLive() {    state messages = chat_thread_demo_start    state waiting = false    if waiting {        every(1500, () => {            messages = messages | push({ id: len(messages) + 1, from: "Ana Silva", text: "Got it, thanks!", time: "now", day: "Today" })            waiting = false        })    }    <ChatThread id="chat-demo-live" messages={messages} typing={waiting ? "Ana Silva" : null} height="340px"                on_send={(text) => {                    messages = messages | push({ id: len(messages) + 1, from: "Me", mine: true, text, time: "now", day: "Today" })                    waiting = true                }}/>}view ChatThreadDemo() =    <div.demo-stack>        <ChatThreadDemoLive/>        <p.demo-note>Enter sends, Shift+Enter starts a new line; Ana answers. Below, a thread whose box posts a form:</p>        <ChatThread id="chat-demo-form" height="180px" method="get" action="#chat_thread" label="Support ticket"                    messages={[{ from: "Support", text: "Thanks for writing in. Could you send us your order number?", time: "Mon 14:20", day: "Monday" },                               { from: "You", mine: true, text: "It's #1042.", time: "Mon 14:31", day: "Monday" }]}>            <input type="hidden" name="ticket" value="77"/>        </ChatThread>    </div>

Source

ui/chat_thread.qwe
// ChatThread: a conversation: messages from you on the right and from others on// the left, with their avatars and names, a line for each new day, messages in a// row from one person grouped together, "Ana is typing", and a box to write in.// Enter sends, Shift+Enter starts a new line. It opens at the newest message and// stays there as new ones come, unless you have scrolled up to read; when you send,// it goes back down. The messages are an ARIA log, so screen readers hear new ones.////   <ChatThread action="/chat/42" messages={msgs}>//       <input type="hidden" name="thread" value="42"/>//   </ChatThread>//   <ChatThread messages={msgs} typing={who} on_send={(text) => { send(text) }}/>//// messages: a list of { from (a name), text, mine?: true (yours), time? ("09:41"),// day? ("Today": a line before the first message of each day), avatar? (an image// URL; else their initials), id? (to keep each one's place) }.// typing: the name of who is typing, or null. label: what screen readers call the// messages ("Messages"). height: how tall the messages are, as CSS ("420px").// In a page, the box is a <form method={method} action={action}> (post, and the// page's own address by default) sending name ("message") = the text, with// children inside it (hidden inputs). Inside one of your components, on_send(text)// is called with the text instead, and the box is emptied.// placeholder ("Write a message…"), send_label ("Send"), disabled. composer: false// shows the messages only (a transcript). id: the text box's id ("ui-chat").import ui/theme.qwe use ui_clslet chat_thread_css = css`.ui-chat {  display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);  background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); overflow: hidden;}.ui-chat-log {  display: flex; flex-direction: column-reverse; height: var(--ui-chat-height, 420px); min-height: 0;  overflow-y: auto; overscroll-behavior: contain; outline: none;}/* At the newest message, it stays there as messages come (no anchoring); scrolled up, it keeps your place. */.ui-chat-stuck { overflow-anchor: none; }.ui-chat-log:focus-visible { box-shadow: inset var(--ui-focus); }.ui-chat-flow { padding: 14px 14px 8px; }.ui-chat-list { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-chat-day {  display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; color: var(--ui-muted); font-size: 12px; font-weight: 500;}.ui-chat-day::before, .ui-chat-day::after { content: ""; flex: 1; height: 1px; background: var(--ui-border); }.ui-chat-first { margin-top: 10px; }.ui-chat-row { display: flex; align-items: flex-end; gap: 8px; }.ui-chat-mine .ui-chat-row { flex-direction: row-reverse; }.ui-chat-avatar {  display: grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 50%; overflow: hidden;  background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 12px; font-weight: 650;}.ui-chat-avatar img { width: 100%; height: 100%; object-fit: cover; }.ui-chat-avatar-off { visibility: hidden; }.ui-chat-body { display: grid; justify-items: start; min-width: 0; max-width: min(75%, 560px); }.ui-chat-mine .ui-chat-body { justify-items: end; }.ui-chat-name { margin: 0 0 2px 12px; color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-chat-bubble {  padding: 8px 12px; border-radius: 16px; background: var(--ui-subtle); color: var(--ui-fg);  white-space: pre-wrap; overflow-wrap: anywhere;}.ui-chat-theirs:not(.ui-chat-last) .ui-chat-bubble { border-bottom-left-radius: 5px; }.ui-chat-theirs:not(.ui-chat-first) .ui-chat-bubble { border-top-left-radius: 5px; }.ui-chat-mine .ui-chat-bubble { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-chat-mine:not(.ui-chat-last) .ui-chat-bubble { border-bottom-right-radius: 5px; }.ui-chat-mine:not(.ui-chat-first) .ui-chat-bubble { border-top-right-radius: 5px; }.ui-chat-time { margin: 3px 12px 0; color: var(--ui-muted); font-size: 11px; }.ui-chat-typing { display: flex; align-items: center; gap: 8px; min-height: 26px; padding: 4px 4px 0 2px; color: var(--ui-muted); font-size: 12px; }.ui-chat-typing:empty { min-height: 0; padding: 0; }.ui-chat-dots { display: inline-flex; gap: 3px; padding: 7px 10px; border-radius: 12px; background: var(--ui-subtle); }.ui-chat-dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--ui-muted); animation: ui-chat-dot 1.2s infinite ease-in-out; }.ui-chat-dots i:nth-child(2) { animation-delay: .15s; }.ui-chat-dots i:nth-child(3) { animation-delay: .3s; }@keyframes ui-chat-dot { 0%, 60%, 100% { opacity: .35; transform: none; } 30% { opacity: 1; transform: translateY(-2px); } }.ui-chat-form { display: flex; align-items: flex-end; gap: 8px; padding: 10px; border-top: 1px solid var(--ui-border); background: var(--ui-bg); }.ui-chat-input {  flex: 1; min-width: 0; min-height: 38px; max-height: 140px; margin: 0; padding: 8px 12px; resize: none; field-sizing: content;  border: 1px solid var(--ui-border); border-radius: 19px; background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font);}.ui-chat-input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-chat-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-chat-send {  display: grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%;  background: var(--ui-accent); color: var(--ui-accent-fg); cursor: pointer;}.ui-chat-send svg { width: 18px; height: 18px; }.ui-chat-send:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-chat-send:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-chat-send:disabled, .ui-chat-input:disabled { opacity: .55; cursor: not-allowed; }@media (max-width: 480px) { .ui-chat-body { max-width: 85%; } .ui-chat-flow { padding: 10px 10px 6px; } }@media (prefers-reduced-motion: reduce) { .ui-chat-dots i { animation: none; opacity: .7; } }`// Up to two initials of a name: "Ana Silva" is "AS".fn chat_thread_initials(name: str) -> str =    upper(join(split(trim(name), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))component ChatThread(messages: list = [], typing: str? = null, label: str = "Messages", name: str = "message",                     action: str? = null, method: str = "post", placeholder: str = "Write a message…",                     send_label: str = "Send", height: str = "420px", disabled: bool = false, composer: bool = true,                     id: str = "ui-chat", on_send = null, class: str? = null, children) {    style global chat_thread_css    state draft = ""    state stuck = true    state log = null    state box = null    let n = len(messages)    let send = () => {        let text = trim(draft)        if text != "" && on_send != null {            // Yours shows at the bottom: go there, even from higher up.            stuck = true            log.scrollTo({ top: log.scrollHeight })            on_send(text)            draft = ""            box.value = ""            box.focus()        }    }    <section class={ui_cls(["ui-chat", class])} style={`--ui-chat-height: ${height}`}>        <div class={ui_cls(["ui-chat-log", stuck && "ui-chat-stuck"])} role="log" aria-label={label} tabindex="0"             @mount={(el) => { log = el }}             @scroll={(e) => { stuck = e.scrollTop > -40 }}>            <div.ui-chat-flow>                <ol.ui-chat-list>                    {for [i, m] in enumerate(messages) => {                        let prev = i > 0 ? messages[i - 1] : null                        let next = i < n - 1 ? messages[i + 1] : null                        let mine = get(m, "mine") == true                        let day = get(m, "day")                        let new_day = day != null && (prev == null || get(prev, "day") != day)                        let next_day = next != null && get(next, "day") != null && get(next, "day") != day                        let first = prev == null || new_day || prev.from != m.from || (get(prev, "mine") == true) != mine                        let last = next == null || next_day || next.from != m.from || (get(next, "mine") == true) != mine                        <li key={get(m, "id") ?? i} class={ui_cls([mine ? "ui-chat-mine" : "ui-chat-theirs",                                                                   first && "ui-chat-first", last && "ui-chat-last"])}>                            {new_day && <div.ui-chat-day>{day}</div>}                            <div.ui-chat-row>                                {!mine && <span class={ui_cls(["ui-chat-avatar", !last && "ui-chat-avatar-off"])} aria-hidden="true">                                    {get(m, "avatar") != null ? <img src={m.avatar} alt=""/> : chat_thread_initials(m.from)}                                </span>}                                <div.ui-chat-body>                                    {first && !mine ? <div.ui-chat-name>{m.from}</div>                                                    : <span.ui-sr>{mine ? "You" : m.from}:</span>}                                    <div.ui-chat-bubble>{m.text}</div>                                    {get(m, "time") != null && <time class={last ? "ui-chat-time" : "ui-sr"}>{m.time}</time>}                                </div>                            </div>                        </li>                    }}                </ol>                <div.ui-chat-typing role="status">{typing != null && [                    <span.ui-chat-dots aria-hidden="true"><i></i><i></i><i></i></span>,                    <span>{typing} is typing…</span>                ]}</div>            </div>        </div>        {composer && <form.ui-chat-form method={method} action={action} @submit={on_send != null ? send : null}>            {children}            <label.ui-sr for={id}>{placeholder}</label>            <textarea.ui-chat-input id={id} name={name} rows="1" placeholder={placeholder} required={true}                      disabled={disabled} enterkeyhint="send"                      @mount={(el) => { box = el }}                      @input={(e) => { draft = e.value }}                      @keydown={(e) => {                          if e.key == "Enter" && !e.shiftKey && !e.isComposing {                              e.preventDefault()                              if trim(e.target.value) != "" {                                  if on_send != null { send() } else { e.target.form.requestSubmit() }                              }                          }                      }}></textarea>            <button.ui-chat-send type="submit" aria-label={send_label} title={send_label} disabled={disabled}>                <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 19V5M5 12l7-7 7 7"/></svg>            </button>        </form>}    </section>}
Thread
44Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.