ChatThread
A conversation: grouped messages with avatars, days, a typing indicator, and a box where Enter sends.
Demo
- YesterdayAna SilvaHi! Did the new build go out?
- You:Not yet: one test is still failing on Windows.
- Ana SilvaOk, no rush.
- TodayAna SilvaMorning! Any luck with that test?
- Ana Silva:I can pair on it after lunch if that helps.
- You:Fixed it: a path with a space in it.
- You:Shipping now 🚀
- Ben OkaforNice one. I'll tell the customers.
Enter sends, Shift+Enter starts a new line; Ana answers. Below, a thread whose box posts a form:
- MondaySupportThanks for writing in. Could you send us your order number?
- 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.qwemain.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>}Related
ActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsLeaderboardPeople ranked by score with medals for the top three, shared places for ties, and your own row kept in view.7 views · 7 downloadsReviewListReviews sorted by a select (newest, highest, most helpful), filtered by stars with counts, shown a page at a time.6 views · 7 downloadsChatWidgetA chat button in the corner that opens a small chat panel, with replies from your server and a count for unseen ones.4 views · 7 downloadsAvatarStackOverlapping avatars with names in tooltips, links to each person, and a +N that names the rest.5 views · 9 downloadsChangelogReleases with version, date and label, their changes grouped by kind: Added, Changed, Fixed, Security and the rest.6 views · 7 downloads
Thread
Thread 0 posts
On ChatThread: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.