MessageList
Nhật ký cuộn của một cuộc trò chuyện: các MessageBubble theo thứ tự, cũ nhất trước, với chỉ báo đang soạn tin sau tin cuối. Nó mở ở tin mới nhất và đi theo tin mới khi người đọc đang ở cuối; khi họ cuộn lên, nó giữ nguyên vị trí và hiện một nút để quay xuống.
Xem trước
import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
import { Separator } from "@/components/ui/separator"
const addReaction = (emoji: string) => { /* Thêm cảm xúc của người đọc vào tin nhắn. */ }
const replyToMessage = () => { /* Đưa tin nhắn vào replyTo của Composer. */ }
const forwardMessage = () => { /* Mở hộp thoại chuyển tiếp. */ }
const setPinned = (pinned: boolean) => { /* Ghim hoặc bỏ ghim tin nhắn. */ }
const startSelection = () => { /* Bật chế độ chọn với tin nhắn này được chọn. */ }
const typingVi = (names: string[]) =>
names.length === 0 ? "Đang soạn tin" : names.length === 1 ? `${names[0]} đang soạn tin` : names.length === 2 ? `${names[0]} và ${names[1]} đang soạn tin` : `${names[0]}, ${names[1]} và ${names.length - 2} người khác đang soạn tin`
const messageListLabelsVi = { jumpToLatest: "Tới tin mới nhất", selected: (count: number) => `Đã chọn ${count}`, typing: typingVi }
const messageActionsLabelsVi = { toolbar: "Thao tác tin nhắn", react: "Bày tỏ cảm xúc", reply: "Trả lời", forward: "Chuyển tiếp", pin: "Ghim", select: "Chọn", moreReactions: "Thêm cảm xúc", quickReactions: "Cảm xúc nhanh" }
<MessageList label="Tin nhắn với Linh" labels={messageListLabelsVi} className="h-80 w-full max-w-md rounded-container border border-border">
<Separator label="Hôm nay" />
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} labels={messageActionsLabelsVi} />}>Bạn gửi bản brief trước buổi gọi được không?</MessageBubble>
<MessageBubble value="m2" side="end" variant="accent" time="10:42" status="read" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} labels={messageActionsLabelsVi} />} labels={{ sending: "Đang gửi", sent: "Đã gửi", delivered: "Đã nhận", read: "Đã xem", failed: "Chưa gửi được", retry: "Thử lại", edited: "Đã sửa", pinned: "Đã ghim", forwarded: "Đã chuyển tiếp", replyTo: "Trả lời", select: "Chọn tin nhắn" }}>Được, mình gửi ngay đây.</MessageBubble>
<MessageBubble value="m3" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} labels={messageActionsLabelsVi} />}>Nhận rồi, cảm ơn nhé!</MessageBubble>
<MessageBubble value="m4" grouped author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:44" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} labels={messageActionsLabelsVi} />}>Mình sẽ đọc trước buổi gọi.</MessageBubble>
</MessageList>Cách dùng
Khi nào dùng
- Hiện các tin nhắn của một cuộc trò chuyện theo thứ tự, đi theo tin mới khi người đọc đang ở cuối.
- Cho thấy người kia hoặc trợ lý đang viết câu trả lời.
Khi nào không dùng
- Một luồng bài đăng hay sự kiện không phải cuộc trò chuyện. Dùng một danh sách Card.
- Thông báo đến rồi đi. Dùng Toast.
- Các bước một agent đã làm. Dùng một danh sách, hoặc Stepper cho một luồng theo thứ tự.
Ví dụ
Cuộc trò chuyện
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
import { Separator } from "@/components/ui/separator"
<MessageList label="Tin nhắn với Linh" className="h-80 w-full max-w-md rounded-container border border-border">
<Separator label="Hôm nay" />
<MessageBubble author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41">Can you send the brief before the call?</MessageBubble>
<MessageBubble side="end" variant="accent" time="10:42" status="read">Được, mình gửi ngay đây.</MessageBubble>
<MessageBubble author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:44">Got it, thanks!</MessageBubble>
</MessageList>Đang soạn tin
Chỉ báo nằm sau tin cuối; chữ của nó đi vào một vùng trạng thái.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
<MessageList typing typingLabel="Linh đang soạn tin" typingAvatar={<Avatar alt="" color="blue">LT</Avatar>} label="Tin nhắn với Linh" className="h-64 w-full max-w-md rounded-container border border-border">
<MessageBubble side="end" variant="accent" time="10:42" status="read">Vẫn hẹn lúc 3 giờ chứ?</MessageBubble>
</MessageList>Nhiều người đang soạn tin
typingUsers nêu tên mọi người đang viết; chữ nói đó là ai, và avatar đầu tiên thẳng hàng với tin của họ.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
const typingPeople = [
{ name: "Linh", avatar: <Avatar alt="" color="blue">LT</Avatar> },
{ name: "An", avatar: <Avatar alt="" color="green">AP</Avatar> },
]
<MessageList typingUsers={typingPeople} label="Nhóm thiết kế" className="h-64 w-full max-w-md rounded-container border border-border">
<MessageBubble author="Minh" avatar={<Avatar alt="" color="purple">M</Avatar>} time="09:12">The new icons are in.</MessageBubble>
</MessageList>Trợ lý đang suy nghĩ
typingVariant plain vẽ các chấm không có bong bóng, ở chỗ câu trả lời plain sẽ hiện.
import { IconSparkles } from "@tabler/icons-react"
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
<MessageList typing typingVariant="plain" typingLabel="Trợ lý đang viết" typingAvatar={<Avatar alt="">
<IconSparkles size={16} />
</Avatar>} label="Trò chuyện với trợ lý" className="h-64 w-full max-w-lg rounded-container border border-border">
<MessageBubble side="end">Tóm tắt báo cáo tháng 10.</MessageBubble>
</MessageList>Trợ lý
Câu trả lời của trợ lý dùng bong bóng plain; tin của chính người đọc giữ bong bóng trung tính ở phía cuối.
import { IconSparkles } from "@tabler/icons-react"
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
<MessageList label="Trò chuyện với trợ lý" className="h-80 w-full max-w-lg rounded-container border border-border">
<MessageBubble side="end">Tóm tắt báo cáo tháng 10.</MessageBubble>
<MessageBubble variant="plain" author="Trợ lý" avatar={<Avatar alt="">
<IconSparkles size={16} />
</Avatar>}>Sales grew in two of three regions. The third held steady, and returns fell for the second month in a row.</MessageBubble>
</MessageList>Chọn tin nhắn
Trong chế độ chọn, mỗi tin có một checkbox; nhấn một hàng để chọn nó, và Shift chọn cả một khoảng.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
const setSelectionMode = (selectionMode: boolean) => { /* Thoát chế độ chọn. */ }
const pickedMessages = ["m2"]
<MessageList selectionMode defaultSelected={pickedMessages} onSelectionModeChange={setSelectionMode} label="Tin nhắn với Linh" className="h-80 w-full max-w-md rounded-container border border-border">
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41">Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m2" side="end" variant="accent" time="10:42" status="read">Được, mình gửi ngay đây.</MessageBubble>
<MessageBubble value="m3" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:44">Got it, thanks!</MessageBubble>
</MessageList>Nên và không nên
Nên
- Cho danh sách một chiều cao cố định hoặc co giãn, như h-96 hoặc flex-1 trong một cột, để nó cuộn thay vì kéo dài trang.
- Chỉ bật typing khi có người đang viết: truyền typingUsers từ sự kiện realtime của bạn (onTypingChange của Composer phía bên kia), và xóa khi tin của họ tới.
- Tách các ngày bằng nhãn Separator, như Hôm nay.
- Để chọn, cho mỗi MessageBubble một value, và hiện các thao tác cho tin được chọn (Chuyển tiếp, Xóa) trong một thanh cạnh danh sách.
Không nên
- Đừng cuộn người đọc xuống khi họ đang đọc tin cũ; giữ stickToBottom bật và để nút quay xuống đưa họ về.
- Đừng dùng Spinner cho đang soạn tin; chỉ báo đang soạn tin là một phần của danh sách.
- Đừng đặt Composer trong danh sách; đặt nó bên dưới.
Props
| Tên | Kiểu | Giá trị | Mặc định | Mô tả |
|---|---|---|---|---|
| label bắt buộc | string | - | - | Tên của nhật ký cho trình đọc màn hình, như Tin nhắn với Linh. |
| typing | boolean | - | false | Hiện chỉ báo đang soạn tin sau tin cuối và đọc một lần ai đang soạn tin. typingUsers cũng bật nó. |
| typingUsers | { name: string; avatar?: React.ReactNode }[] | - | - | Những người đang soạn tin lúc này. Có bất kỳ ai là typing bật và tên họ hiện cạnh các chấm (EN `Linh and An are typing`). Avatar của một người thẳng hàng với tin của họ; từ hai người trở lên thì chồng lên nhau trong một AvatarStack, tối đa ba và +N. |
| typingLabel | string | - | - | Chữ nói ai đang soạn tin, thay cho chữ dựng từ typingUsers, như Linh đang soạn tin. |
| typingAvatar | node | - | - | Một Avatar cạnh chỉ báo đang soạn tin, để nó thẳng hàng với tin của người đó. Truyền alt="". |
| typingVariant | enum | default | plain | default | default vẽ các chấm trong bong bóng trung tính, như tin của một người; plain vẽ chúng không có bong bóng, cho trợ lý viết câu trả lời dạng plain. |
| stickToBottom | boolean | - | true | Đi theo tin mới, và các câu trả lời dài dần khi đang được gửi tới, khi người đọc đang ở cuối. |
| selectionMode | boolean | - | false | Hiện checkbox trên mọi MessageBubble có value. Nhấn một tin để chọn nó; Shift kèm một cú nhấp chọn mọi tin giữa nó và tin được chọn gần nhất. |
| onSelectionModeChange | (selectionMode: boolean) => void | - | - | Được gọi với false khi nhấn Escape trong chế độ chọn, để bạn thoát chế độ này. |
| selected | string[] | - | - | Value của các tin được chọn, cho lựa chọn được kiểm soát. |
| defaultSelected | string[] | - | - | Value được chọn ở lần render đầu, cho lựa chọn không kiểm soát. |
| onSelectedChange | (selected: string[]) => void | - | - | Được gọi với các value được chọn, theo thứ tự tin nhắn. |
| labels | { jumpToLatest?: string; selected?: (count: number) => string; typing?: (names: string[]) => string } | - | - | Tên của nút quay xuống và chữ được đọc cho lựa chọn và cho người đang soạn tin. Mặc định: Jump to latest, 2 selected, Typing, Linh is typing, Linh and An are typing, Linh, An and 2 others are typing. Tiếng Việt: Tới tin mới nhất, Đã chọn 2, Đang soạn tin, Linh đang soạn tin, Linh và An đang soạn tin, Linh, An và 2 người khác đang soạn tin. |
| children bắt buộc | node | - | - | Các MessageBubble, cũ nhất trước, với nhãn Separator giữa các ngày. |
Cấu tạo
| Phần | Mô tả |
|---|---|
| root | Khung. Cho nó một chiều cao (h-96, flex-1); vùng xem lấp đầy nó. |
| viewport | Nhật ký cuộn. |
| content | Cột tin nhắn, có padding để vòng focus không chạm mép. |
| typing | Hàng đang soạn tin sau tin cuối: avatar của người đang gõ, hoặc một AvatarStack khi có từ hai người, các chấm và tên người đang soạn tin. |
| typingBubble | Một bong bóng trung tính cao một dòng với ba chấm; với typingVariant plain, chỉ có các chấm. |
| typingText | Ai đang soạn tin, bằng chữ, cạnh các chấm, bằng màu chữ phụ, khi typingUsers nêu tên họ. Chỉ được vẽ: vùng trạng thái đọc cùng chữ đó. |
| dot | Một chấm 6px. Ba chấm nhấp nháy lần lượt theo chu kỳ duration-loop và đứng yên khi giảm chuyển động. |
| jump | Button icon secondary ở dưới cùng, cuộn về tin mới nhất, hiện khi người đọc rời khỏi cuối. |
Khả năng truy cập
Role: log
| Phím | Hành động |
|---|---|
| Tab | Chuyển focus tới nhật ký, rồi tới các liên kết và thao tác trong tin nhắn. |
| Arrow Up / Arrow Down | Khi nhật ký có focus, cuộn qua các tin nhắn. |
| Page Up / Page Down, Home / End | Khi nhật ký có focus, cuộn từng trang, hoặc tới tin đầu tiên hay tin mới nhất. |
| Escape | Trong chế độ chọn, gọi onSelectionModeChange(false). |
- Vùng cuộn là một log được đặt tên bằng label (EN `Messages with Linh`), nên trình đọc màn hình đọc tin mới khi chúng tới, một cách lịch sự.
- Nhật ký nhận focus, để bàn phím cuộn được nó, và hiện vòng focus.
- Chỉ báo đang soạn tin chỉ được vẽ. Chữ của nó (EN `Linh is typing`, `Linh and An are typing`) đi vào một vùng trạng thái riêng ngoài nhật ký, nên được đọc một lần khi bắt đầu gõ hoặc khi người gõ thay đổi, và không bao giờ như một tin nhắn.
- Nút quay xuống được đặt tên bằng labels.jumpToLatest (EN `Jump to latest`, VI `Tới tin mới nhất`). Khi người đọc ở cuối, nó bị ẩn và ra khỏi thứ tự tab.
- Việc đi theo tin mới không bao giờ di chuyển focus. Khi quay xuống, focus về lại nhật ký, vì nút bị ẩn.
- Trong chế độ chọn, một vùng trạng thái thứ hai đọc số lượng, EN `2 selected`, VI `Đã chọn 2`, khi nó thay đổi.
Cài đặt
npx werewolf-ui add message-listChạy npx werewolf-ui init một lần trong app trước khi thêm component. Init cài token, thêm CSS import và luật cho agent. Add cài component này cùng các dependency và giữ nguyên theme của bạn. Xem Cài đặt để biết yêu cầu và cách cài qua registry.