Howdy

Chat

Chat: a conversation that keeps to its newest message, messages with an avatar and a header, speech bubbles, reactions and notes.

componentimport howdy/ui/chatSince Howdy 2.0
gleam run -m howdy/ui add chat

Conversation

GH
Grace · 09:41
Did the deploy go out?
The one with the new invoices page.
Deploy 142 finished
Ten minutes ago. All green.
Read 09:43
GH
Grace is typing…
ui.chat_conversation(
  [attribute.aria_label("Messages"), attribute.style("height", "22rem")],
  [
    ui.chat_note([text("Today")]),
    ui.chat_message(
      Incoming,
      attributes: [attribute.id("example-question")],
      avatar: ui.avatar_initials("GH"),
      header: [text("Grace · 09:41")],
      content: [
        ui.chat_bubble(Incoming, [text("Did the deploy go out?")]),
        ui.chat_bubble(Incoming, [text("The one with the new invoices page.")]),
      ],
      footer: [],
    ),
    chat.status(chat.Neutral, text("✓"), [text("Deploy 142 finished")]),
    chat.tinted_note(chat.Accent, [text("New messages")]),
    ui.chat_message(
      Outgoing,
      attributes: [],
      avatar: element.none(),
      header: [],
      content: [
        chat.jump("example-question", [text("Grace: Did the deploy go out?")]),
        ui.chat_bubble(Outgoing, [text("Ten minutes ago. All green.")]),
        chat.reactions([
          chat.reaction("🎉", 2, [attribute.aria_pressed("true")]),
        ]),
      ],
      footer: [text("Read 09:43")],
    ),
    ui.chat_message(
      Incoming,
      attributes: [],
      avatar: ui.avatar_initials("GH"),
      header: [],
      content: [
        chat.styled_bubble(Incoming, chat.Outline, [
          ui.shimmer([text("Grace is typing…")]),
        ]),
      ],
      footer: [],
    ),
  ],
)

Long history

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12
Message 13
Message 14
Message 15
Message 16
Message 17
Message 18
Message 19
Message 20
Message 21
Message 22
Message 23
Message 24
Message 25
Message 26
Message 27
Message 28
Message 29
Message 30
chat.conversation(
  [
    attribute.aria_label("History"),
    attribute.style("height", "14rem"),
    chat.remember("example-history"),
    chat.start_at("example-history-12"),
  ],
  [
    chat.note([text("Older messages load as you scroll back")]),
    ..list.map(numbers(1, 30), fn(n) {
      let n = int.to_string(n)
      chat.message(
        Incoming,
        attributes: [attribute.id("example-history-" <> n)],
        avatar: element.none(),
        header: [],
        content: [chat.bubble(Incoming, [text("Message " <> n)])],
        footer: [],
      )
    })
  ],
)
Full source of howdy/ui/chat
//// Chat: a conversation that keeps to its newest message, messages with an
//// avatar and a header, speech bubbles, reactions and notes.
////
//// ```gleam
//// chat.conversation([attribute.aria_label("Messages")], [
////   chat.note([text("Today")]),
////   chat.message(chat.Incoming, avatar: avatar.initials("GH"), header: [text("Grace · 09:41")], content: [
////     chat.bubble(chat.Incoming, [text("Did the deploy go out?")]),
////   ]),
////   chat.message(chat.Outgoing, avatar: element.none(), header: [], content: [
////     chat.bubble(chat.Outgoing, [text("Ten minutes ago.")]),
////     chat.reactions([chat.reaction("👍", 2, [])]),
////   ]),
//// ])
//// ```
////
//// A conversation stays at its newest message as messages arrive or a reply
//// streams in, and keeps its place when you scroll back to read. It needs no
//// script: it is laid out from the bottom, and the browser anchors the
//// scroll position. It is a `log` for screen readers, so they announce new
//// messages. Give each message an `id` to link to it.
////
//// Older history can be added above the first message, such as when
//// `on_older` asks for it, and the reader's place stays put. `remember`
//// brings a reader back to where they were after a reload, and
//// `start_at` opens the conversation at a message, such as the first
//// unread one.

import gleam/dynamic/decode
import gleam/int
import gleam/list
import howdy/ui/style.{class}
import howdy/ui/theme/tokens
import lustre/attribute.{type Attribute}
import lustre/element.{type Element, text}
import lustre/element/html
import lustre/event
import sketch/css.{type Class}
import sketch/css/length.{percent, rem}

/// Who wrote a message: someone else, or the person reading.
pub type Side {
  Incoming
  Outgoing
}

/// A bubble's surface.
pub type Variant {
  /// The primary colour: the reader's own messages, by default.
  Filled
  /// The muted colour: everyone else's messages, by default.
  Muted
  Outline
  /// No surface, for long replies that read better as plain text.
  Ghost
  /// The danger colour, such as for a message that failed to send.
  Danger
}

/// How a note or status line stands out.
pub type Tone {
  Neutral
  /// The primary colour, such as for "New messages".
  Accent
  /// The danger colour, such as for "Deploy failed".
  Critical
}

/// The scrolling list of messages. Size it with a height or by placing it
/// in a flex column; label it with `aria-label`. When it is scrolled back
/// from the newest message, a "Jump to newest" button appears.
pub fn conversation(
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  conversation_with("Jump to newest", attributes, children)
}

/// A conversation whose jump button says something else, such as in
/// another language.
pub fn conversation_with(
  jump: String,
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.div([class(frame_class()), attribute.data("howdy-conversation", "")], [
    html.div([class(scroller_class()), attribute.tabindex(0), ..attributes], [
      html.div([class(log_class()), attribute.role("log")], children),
    ]),
    html.button(
      [
        class(latest_class()),
        attribute.type_("button"),
        attribute.data("howdy-latest", ""),
        attribute.hidden(True),
      ],
      [text(jump), html.span([attribute.aria_hidden(True)], [text(" ↓")])],
    ),
  ])
}

/// Keep the reader's place in this conversation, named `key`, for the rest
/// of the browser session, and go back to it when the page is reloaded.
/// Put it in the conversation's attributes.
pub fn remember(key: String) -> Attribute(msg) {
  attribute.data("howdy-remember", key)
}

/// Open the conversation with the message with this id at the top, such as
/// the first unread one, instead of at the newest. A remembered place wins.
pub fn start_at(id: String) -> Attribute(msg) {
  attribute.data("howdy-start-at", id)
}

/// Hear when the reader has scrolled back near the oldest message shown,
/// to load older ones and add them above it. It is sent once until more
/// messages arrive. Put it in the conversation's attributes.
pub fn on_older(message: msg) -> Attribute(msg) {
  event.on("howdy-older", decode.success(message))
}

/// One message, or a run of messages from one person: an avatar beside a
/// header, the content and a footer. Several bubbles in the content join
/// into one group. Pass `element.none()` for no avatar, and `[]` for no
/// header or footer. Give it an `id` in `attributes` to link to it with
/// `jump`.
pub fn message(
  side: Side,
  attributes attributes: List(Attribute(msg)),
  avatar avatar: Element(msg),
  header header: List(Element(msg)),
  content content: List(Element(msg)),
  footer footer: List(Element(msg)),
) -> Element(msg) {
  let header = case header {
    [] -> element.none()
    _ -> html.div([class(header_class())], header)
  }
  let footer = case footer {
    [] -> element.none()
    _ -> html.div([class(footer_class())], footer)
  }
  html.article([class(message_class(side)), ..attributes], [
    avatar,
    html.div([class(body_class(side))], [
      header,
      ..list.append(content, [footer])
    ]),
  ])
}

/// A speech bubble: filled with the primary colour for your own messages,
/// the muted colour for everyone else's.
pub fn bubble(side: Side, children: List(Element(msg))) -> Element(msg) {
  let variant = case side {
    Incoming -> Muted
    Outgoing -> Filled
  }
  styled_bubble(side, variant, children)
}

/// A bubble with another surface. `side` still decides which corner points
/// at its sender.
pub fn styled_bubble(
  side: Side,
  variant: Variant,
  children: List(Element(msg)),
) -> Element(msg) {
  html.div(
    [class(bubble_class(side, variant)), attribute.data("bubble", "")],
    children,
  )
}

/// A row of reactions under a message.
pub fn reactions(children: List(Element(msg))) -> Element(msg) {
  html.div([class(reactions_class())], children)
}

/// A reaction and how many people chose it. Mark the reader's own with
/// `attribute.aria_pressed("true")`, and add a click handler to toggle it.
pub fn reaction(
  emoji: String,
  count: Int,
  attributes: List(Attribute(msg)),
) -> Element(msg) {
  html.button(
    [class(reaction_class()), attribute.type_("button"), ..attributes],
    [text(emoji <> " " <> int.to_string(count))],
  )
}

/// A line between messages, such as a date or "Grace joined".
pub fn note(children: List(Element(msg))) -> Element(msg) {
  tinted_note(Neutral, children)
}

/// A line between messages in a tone, such as "New messages" in the
/// accent colour.
pub fn tinted_note(tone: Tone, children: List(Element(msg))) -> Element(msg) {
  html.div([class(note_class(tone)), attribute.role("separator")], [
    html.span([], children),
  ])
}

/// A line of news from the system rather than a person, with an icon:
/// "Deploy finished", "Grace changed the topic".
pub fn status(
  tone: Tone,
  icon: Element(msg),
  children: List(Element(msg)),
) -> Element(msg) {
  html.div([class(status_class(tone))], [
    html.span([class(status_icon_class()), attribute.aria_hidden(True)], [icon]),
    html.span([], children),
  ])
}

/// A button that scrolls the conversation to the message with this id and
/// highlights it for a moment, such as a reply's quote of what it answers.
pub fn jump(id: String, children: List(Element(msg))) -> Element(msg) {
  html.button(
    [
      class(jump_class()),
      attribute.type_("button"),
      attribute.data("howdy-jump", id),
    ],
    children,
  )
}

/// Every class this module uses, for `howdy/ui/export`.
pub fn classes() -> List(Class) {
  let sides = [Incoming, Outgoing]
  let variants = [Filled, Muted, Outline, Ghost, Danger]
  let tones = [Neutral, Accent, Critical]
  list.flatten([
    [frame_class(), scroller_class(), log_class(), latest_class()],
    list.map(sides, message_class),
    list.map(sides, body_class),
    [header_class(), footer_class()],
    list.flat_map(sides, fn(side) {
      list.map(variants, fn(variant) { bubble_class(side, variant) })
    }),
    [reactions_class(), reaction_class(), jump_class(), status_icon_class()],
    list.map(tones, note_class),
    list.map(tones, status_class),
  ])
}

pub fn frame_class() -> Class {
  css.class([
    css.position("relative"),
    css.display("flex"),
    css.flex_direction("column"),
    css.property("min-height", "0"),
  ])
}

pub fn latest_class() -> Class {
  css.class([
    css.position("absolute"),
    css.property("bottom", tokens.space_3),
    css.property("inset-inline-start", "50%"),
    css.transform_("translateX(-50%)"),
    css.padding_(tokens.space_1 <> " " <> tokens.space_3),
    style.bordered(),
    style.pill(),
    css.background(tokens.surface),
    css.color(tokens.text),
    css.box_shadow("0 4px 12px -4px rgb(0 0 0 / 0.3)"),
    css.font_family(tokens.font_body),
    css.font_size(rem(0.8125)),
    css.cursor("pointer"),
    css.selector("[hidden]", [css.display("none")]),
    css.selector(":dir(rtl)", [css.transform_("translateX(50%)")]),
    style.focus_ring(),
  ])
}

pub fn scroller_class() -> Class {
  css.class([
    css.display("flex"),
    // Laid out from the bottom, so the newest message is in view and the
    // browser keeps it there as content grows.
    css.flex_direction("column-reverse"),
    css.overflow_y("auto"),
    css.property("overscroll-behavior", "contain"),
    style.focus_ring(),
  ])
}

pub fn log_class() -> Class {
  css.class([
    // Never squeezed: it is the content the scroller scrolls.
    css.flex_shrink(0.0),
    css.display("flex"),
    css.flex_direction("column"),
    css.gap(rem(0.75)),
    css.padding(rem(1.0)),
  ])
}

pub fn message_class(side: Side) -> Class {
  let direction = case side {
    Incoming -> "row"
    Outgoing -> "row-reverse"
  }
  css.class([
    css.display("flex"),
    css.flex_direction(direction),
    css.align_items("flex-end"),
    css.gap(rem(0.5)),
    css.property("scroll-margin", "1rem"),
    style.radius(tokens.radius_medium),
    css.transition("background 300ms"),
    // Picked out for a moment after a `jump` to it.
    css.selector("[data-flash]", [css.background(tokens.muted)]),
  ])
}

pub fn body_class(side: Side) -> Class {
  let #(align, joined) = case side {
    Incoming -> #("flex-start", "border-start-start-radius")
    Outgoing -> #("flex-end", "border-start-end-radius")
  }
  css.class([
    css.display("flex"),
    css.flex_direction("column"),
    css.align_items(align),
    css.gap(rem(0.125)),
    css.property("max-width", "min(36rem, 80%)"),
    // Bubbles in a run join: each after the first squares its corner on
    // the sender's side, below the one before.
    css.selector(" > [data-bubble] + [data-bubble]", [
      css.property(joined, tokens.radius_small),
    ]),
  ])
}

pub fn footer_class() -> Class {
  css.class([
    css.display("flex"),
    css.flex_wrap("wrap"),
    css.align_items("center"),
    css.gap(rem(0.5)),
    css.padding_("0.125rem " <> tokens.space_1 <> " 0"),
    css.font_size(rem(0.75)),
    css.color(tokens.text_muted),
  ])
}

pub fn header_class() -> Class {
  css.class([
    css.padding_("0 " <> tokens.space_1),
    css.font_size(rem(0.75)),
    css.color(tokens.text_muted),
  ])
}

pub fn bubble_class(side: Side, variant: Variant) -> Class {
  let corner = case side {
    Incoming -> "border-end-start-radius"
    Outgoing -> "border-end-end-radius"
  }
  let surface = case variant {
    Filled -> [css.background(tokens.primary), css.color(tokens.on_primary)]
    Muted -> [css.background(tokens.muted), css.color(tokens.text)]
    Outline -> [
      css.background(tokens.surface),
      css.color(tokens.text),
      style.bordered(),
    ]
    Ghost -> [
      css.background("transparent"),
      css.color(tokens.text),
      css.property("padding-inline", "0"),
    ]
    Danger -> [css.background(tokens.danger), css.color(tokens.on_danger)]
  }
  css.class([
    css.padding_(tokens.space_2 <> " " <> tokens.space_3),
    style.radius(tokens.radius_large),
    css.property(corner, tokens.radius_small),
    ..list.append(surface, [
      css.font_size(rem(0.9375)),
      css.line_height("1.45"),
      css.property("overflow-wrap", "anywhere"),
      css.white_space("pre-wrap"),
      css.max_width(percent(100)),
    ])
  ])
}

pub fn reactions_class() -> Class {
  css.class([css.display("flex"), css.flex_wrap("wrap"), css.gap(rem(0.25))])
}

pub fn reaction_class() -> Class {
  css.class([
    css.padding_("0.125rem " <> tokens.space_2),
    style.bordered(),
    style.pill(),
    css.background(tokens.surface),
    css.color(tokens.text),
    css.font_size(rem(0.75)),
    css.cursor("pointer"),
    css.hover([css.background(tokens.muted)]),
    style.focus_ring(),
    css.selector("[aria-pressed=\"true\"]", [
      css.property("border-color", tokens.primary),
      css.background(tokens.muted),
    ]),
  ])
}

fn tone_colour(tone: Tone) -> String {
  case tone {
    Neutral -> tokens.text_muted
    Accent -> tokens.primary
    Critical -> tokens.danger
  }
}

pub fn note_class(tone: Tone) -> Class {
  let line = case tone {
    Neutral -> tokens.border
    _ -> tone_colour(tone)
  }
  css.class([
    css.display("flex"),
    css.align_items("center"),
    css.gap(rem(0.75)),
    css.font_size(rem(0.75)),
    css.font_weight(case tone {
      Neutral -> "400"
      _ -> "500"
    }),
    css.color(tone_colour(tone)),
    css.before([
      css.content("\"\""),
      css.property("flex", "1"),
      css.property("border-top", "1px solid " <> line),
    ]),
    css.after([
      css.content("\"\""),
      css.property("flex", "1"),
      css.property("border-top", "1px solid " <> line),
    ]),
  ])
}

pub fn status_class(tone: Tone) -> Class {
  css.class([
    css.display("flex"),
    css.align_items("center"),
    css.justify_content("center"),
    css.gap(rem(0.5)),
    css.font_size(rem(0.8125)),
    css.color(case tone {
      Neutral -> tokens.text_muted
      _ -> tone_colour(tone)
    }),
  ])
}

pub fn status_icon_class() -> Class {
  css.class([
    css.display("inline-flex"),
    css.align_items("center"),
    css.justify_content("center"),
    css.property("width", "1.25rem"),
    css.property("height", "1.25rem"),
    style.pill(),
    css.background(tokens.muted),
    css.font_size(rem(0.75)),
  ])
}

pub fn jump_class() -> Class {
  css.class([
    css.display("block"),
    css.padding_(tokens.space_1 <> " " <> tokens.space_2),
    css.border("0"),
    css.property("border-inline-start", "3px solid " <> tokens.border),
    style.radius(tokens.radius_small),
    css.background(tokens.muted),
    css.color(tokens.text_muted),
    css.font_family(tokens.font_body),
    css.font_size(rem(0.8125)),
    css.text_align("start"),
    css.cursor("pointer"),
    css.hover([css.color(tokens.text)]),
    style.focus_ring(),
  ])
}