HextaUI

Message

Avatar, ad, balonlar ve durum içeren bir sohbet mesajı satırı; yeni mesajlar göndericinin tarafından yukarı doğru belirir.

PS
Preet
Did the message component land?
Send me something and see how it arrives.
"use client"

import * as React from "react"
import { IconArrowUp, IconChecks } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import { Marker, MarkerContent, MarkerTime } from "@/components/ui/marker"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

type Entry = { id: number; from: "me" | "preet"; text: string }

const replies = [
  "Ha, that rise-in is nice.",
  "Try sending a few in a row.",
  "Each one comes in from your side.",
]

export function MessageDemo() {
  const [entries, setEntries] = React.useState<Entry[]>([
    { id: 1, from: "preet", text: "Did the message component land?" },
    { id: 2, from: "preet", text: "Send me something and see how it arrives." },
  ])
  const [draft, setDraft] = React.useState("")
  const nextId = React.useRef(3)
  const replyIndex = React.useRef(0)
  const replyTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const threadRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => () => clearTimeout(replyTimer.current), [])

  React.useEffect(() => {
    const thread = threadRef.current
    if (thread) {
      thread.scrollTo({ top: thread.scrollHeight, behavior: "smooth" })
    }
  }, [entries])

  const send = () => {
    const text = draft.trim()
    if (!text) {
      return
    }
    setEntries((current) => [
      ...current,
      { id: nextId.current++, from: "me", text },
    ])
    setDraft("")
    clearTimeout(replyTimer.current)
    replyTimer.current = setTimeout(() => {
      const reply = replies[replyIndex.current % replies.length]
      replyIndex.current += 1
      setEntries((current) => [
        ...current,
        { id: nextId.current++, from: "preet", text: reply },
      ])
    }, 900)
  }

  const runs = entries.reduce<Entry[][]>((groups, entry) => {
    const last = groups.at(-1)
    if (last && last[0].from === entry.from) {
      last.push(entry)
    } else {
      groups.push([entry])
    }
    return groups
  }, [])

  return (
    <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border">
      <div
        ref={threadRef}
        className="h-80 overflow-y-auto overscroll-none px-4 py-4"
      >
        <MessageGroup>
          <Marker variant="separator">
            <MarkerContent>
              <MarkerTime date={new Date()} />
            </MarkerContent>
          </Marker>
          {runs.map((run, index) => {
            const mine = run[0].from === "me"
            const lastRun = index === runs.length - 1
            return (
              <Message key={run[0].id} align={mine ? "end" : "start"}>
                {!mine && (
                  <MessageAvatar>
                    <Avatar>
                      <AvatarImage
                        src="https://github.com/preetsuthar17.png"
                        alt=""
                      />
                      <AvatarFallback>PS</AvatarFallback>
                    </Avatar>
                  </MessageAvatar>
                )}
                <MessageContent>
                  {!mine && <MessageHeader>Preet</MessageHeader>}
                  <BubbleGroup shape="tail">
                    {run.map((entry) => (
                      <Bubble
                        key={entry.id}
                        variant={mine ? "default" : "secondary"}
                      >
                        <BubbleContent>{entry.text}</BubbleContent>
                      </Bubble>
                    ))}
                  </BubbleGroup>
                  {mine && lastRun && (
                    <MessageFooter>
                      <IconChecks />
                      Read
                    </MessageFooter>
                  )}
                </MessageContent>
              </Message>
            )
          })}
        </MessageGroup>
      </div>
      <form
        className="border-t p-3"
        onSubmit={(event) => {
          event.preventDefault()
          send()
        }}
      >
        <InputGroup>
          <InputGroupTextarea
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (
                event.key === "Enter" &&
                !event.shiftKey &&
                !event.nativeEvent.isComposing
              ) {
                event.preventDefault()
                send()
              }
            }}
            rows={1}
            placeholder="Message Preet"
            aria-label="Message"
            className="min-h-10"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              type="submit"
              variant="default"
              size="icon-xs"
              aria-label="Send"
              disabled={!draft.trim()}
            >
              <IconArrowUp />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </form>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/message.json

Bileşeni, HextaUI tema token'larını ve bileşenin bağımlı olduğu tüm HextaUI bileşenlerini ekler.

import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"
<MessageGroup>
  <Message align="end">
    <MessageContent>
      <Bubble>
        <BubbleContent>On my way.</BubbleContent>
      </Bubble>
      <MessageFooter>Read</MessageFooter>
    </MessageContent>
  </Message>
</MessageGroup>

Bir mesaj; bir avatarı, bir adı, bir veya daha fazla <Bubble />'i ve bir durum satırını dizer. Balonlar mesajın align değerini devralır, böylece bir kez ayarlarsınız.

MessageGroup
└── Message
    ├── MessageAvatar
    └── MessageContent
        ├── MessageHeader
        ├── Bubble
        └── MessageFooter

Hizalama

align="end" kendi mesajlarınız için avatarı ve balonları diğer tarafa koyar.

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
} from "@/components/ui/message"

export function MessageAlignment() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <Bubble variant="secondary" shape="tail">
            <BubbleContent>Start is for the people you talk to.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar>
          <Avatar>
            <AvatarFallback>ME</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <Bubble shape="tail">
            <BubbleContent>End is for you. The bubble follows.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

<MessageHeader /> ad ve zamanı, <MessageFooter /> ise tek satırlık bir durumu tutar. Avatar, alt bilgi olsa da olmasa da her zaman son balonla hizalanır.

import { IconChecks, IconClock } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageHeaderFooter() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>
            Preet Suthar
            <span className="font-normal">9:41</span>
          </MessageHeader>
          <BubbleGroup shape="tail">
            <Bubble variant="secondary">
              <BubbleContent>Pushed the fix.</BubbleContent>
            </Bubble>
            <Bubble variant="secondary">
              <BubbleContent>Can you check the preview?</BubbleContent>
            </Bubble>
          </BubbleGroup>
          <MessageFooter>Edited</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble shape="tail">
            <BubbleContent>Looks right. Merging.</BubbleContent>
          </Bubble>
          <MessageFooter>
            <IconChecks />
            Read 9:43
          </MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble variant="muted" shape="tail">
            <BubbleContent>Sending the release notes…</BubbleContent>
          </Bubble>
          <MessageFooter>
            <IconClock />
            Sending
          </MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

Asistan

ghost balon uzun cevaplar için tam genişliği kaplar, <MessageAvatar /> içindeki bir simge ise yuvarlak bir kutucuğa dönüşür.

import { IconSparkles } from "@tabler/icons-react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageAssistant() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message align="end">
        <MessageContent>
          <Bubble variant="secondary">
            <BubbleContent>What does a concentric radius mean?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageAvatar>
          <IconSparkles />
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <Bubble variant="ghost">
            <BubbleContent>
              An inner corner whose radius is the outer radius minus the gap
              between the two edges. Nested shapes then curve together instead
              of fighting each other at the corners.
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

Uzun içerik

Balonlar uzun bağlantıları sarar; üst ve alt bilgi metni avatarı itmeden kısaltılabilir.

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/ui/message"

export function MessageLongContent() {
  return (
    <div className="w-72 max-w-full">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>
            <span className="truncate">
              Preet Suthar from the HextaUI design systems team
            </span>
          </MessageHeader>
          <Bubble variant="secondary">
            <BubbleContent>
              https://example.com/a/very/long/link/that/does/not/break/naturally/at/all
            </BubbleContent>
          </Bubble>
          <MessageFooter>
            <span className="truncate">
              Delivered to every device signed in to this workspace
            </span>
          </MessageFooter>
        </MessageContent>
      </Message>
    </div>
  )
}

Sağdan sola

Başlangıç ve bitiş taraf değiştirir, yeni mesajlar eşleşen köşeden büyür.

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <MessageGroup>
        <Message>
          <MessageAvatar>
            <Avatar>
              <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
              <AvatarFallback>م</AvatarFallback>
            </Avatar>
          </MessageAvatar>
          <MessageContent>
            <MessageHeader>ميرا</MessageHeader>
            <Bubble variant="secondary">
              <BubbleContent>هل وصل المكون الجديد؟</BubbleContent>
            </Bubble>
          </MessageContent>
        </Message>
        <Message align="end">
          <MessageContent>
            <Bubble>
              <BubbleContent>نعم، إنه على الصفحة الآن.</BubbleContent>
            </Bubble>
            <MessageFooter>تمت القراءة</MessageFooter>
          </MessageContent>
        </Message>
      </MessageGroup>
    </div>
  )
}
  • Mesajlar düz içeriktir. Canlı bir sohbet için listeyi bir role="log" öğesiyle sarın; böylece ekran okuyucular yeni mesajları geldikçe okur.
  • Hizalama tek başına mesajı kimin yazdığını söylemediği için gönderenin adını bir başlığa veya görsel olarak gizli metne koyun.
  • Giriş animasyonu yalnızca liste ilk işlendikten sonra eklenen mesajlar için oynar. Azaltılmış harekette solarak girerler.

Her parça bir <div> işler ve render kabul eder.

Konuşma. İlk işlemeden sonra eklenen mesajlar animasyonla girer.

ÖznitelikAçıklama
data-slot="message-group"CSS'te konuşmayı hedefler.
PropTürVarsayılan
alignKonuşmanın tarafı. Balonlar bunu devralır.
"start" | "end""start"
animatedİlk işlemeden sonra bir MessageGroup'a eklendiğinde yukarı doğru belirir.
booleantrue
render
ReactElement | (props, state) => ReactElement<div>
ÖznitelikAçıklama
data-slot="message"CSS'te mesajları hedefler.
data-alignTaraf.
data-enteringAnimasyonla giren mesajlarda bulunur.
ÖznitelikAçıklama
data-slot="message-avatar"Son balonla hizalanır. Çıplak bir simge veya görsel 32px'e boyutlandırılır.
ÖznitelikAçıklama
data-slot="message-content"Başlığı, balonları ve alt bilgiyi üst üste dizer.
ÖznitelikAçıklama
data-slot="message-header"Balonların üstünde ad ve zaman.
data-slot="message-footer"Altta tek satırlık durum. Avatar hizalı kalsın diye tek satırda tutun.

Kullanıldığı bloklar

Message üzerine kurulan bloklar.