feat(blocks): add Mini Program customer service workspace

Add a reusable one-to-one chat workspace modeled on WeChat Mini Program customer-service messages, covering text, image, link, and Mini Program page payloads. Include conversation search, message history, responsive navigation, media resolution, Unicode emoji selection, Lexical plain-text composition, attachments, localized copy, and tests for rendering and send behavior.
This commit is contained in:
Maofeng
2026-08-08 21:14:26 +08:00
parent 2c05cfa86d
commit 87df34a8ab
22 changed files with 2441 additions and 4 deletions
+2
View File
@@ -11,6 +11,7 @@
"@base-ui/react": "^1.6.0",
"@base-ui/utils": "^0.3.1",
"@floating-ui/utils": "^0.2.12",
"@lexical/react": "^0.48.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
@@ -18,6 +19,7 @@
"@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*",
"cmdk": "^1.1.1",
"lexical": "^0.48.0",
"lucide-react": "^1.27.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
@@ -0,0 +1,526 @@
import * as React from "react"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import { Checkbox } from "@workspace/ui/components/checkbox"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover"
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
type LexicalEditor,
} from "lexical"
import {
ImagePlusIcon,
PaperclipIcon,
SendHorizontalIcon,
SmileIcon,
XIcon,
} from "lucide-react"
import { customerServiceEmojiGroups, customerServiceEmojis } from "./emojis"
import { chatMessages } from "./messages"
import type { ChatConversation, ChatSendHandler } from "./types"
interface ChatComposerProps {
conversation: ChatConversation
disabled?: boolean
draft: string
onDraftChange: (draft: string) => void
onSend?: ChatSendHandler
onSendOnEnterChange: (sendOnEnter: boolean) => void
sendOnEnter: boolean
}
export function ChatComposer({
conversation,
disabled = false,
draft,
onDraftChange,
onSend,
onSendOnEnterChange,
sendOnEnter,
}: ChatComposerProps) {
const t = useTranslate()
const fileInputRef = React.useRef<HTMLInputElement>(null)
const imageInputRef = React.useRef<HTMLInputElement>(null)
const editorRef = React.useRef<LexicalEditor>(null)
const initialDraftRef = React.useRef(draft)
const [files, setFiles] = React.useState<readonly File[]>([])
const [isSending, setIsSending] = React.useState(false)
const [sendFailed, setSendFailed] = React.useState(false)
const canSend =
Boolean(onSend) && (draft.trim().length > 0 || files.length > 0)
const editorDisabled = disabled || isSending || !onSend
const initialConfig = React.useMemo(
() => ({
editorState: () => {
const root = $getRoot()
const paragraph = $createParagraphNode()
if (initialDraftRef.current)
paragraph.append($createTextNode(initialDraftRef.current))
root.clear().append(paragraph)
},
namespace: "workspace-chat-composer",
onError: (error: Error) => {
throw error
},
}),
[]
)
const appendFiles = (nextFiles: FileList | null) => {
if (!nextFiles) return
setFiles((current) => [...current, ...Array.from(nextFiles)])
setSendFailed(false)
}
const send = async () => {
const text = serializeWeChatPlainText(editorRef.current, draft)
if (!onSend || (!text && files.length === 0) || isSending) return
setIsSending(true)
setSendFailed(false)
try {
await onSend({ conversation, files, text })
setFiles([])
onDraftChange("")
} catch {
setSendFailed(true)
} finally {
setIsSending(false)
}
}
return (
<LexicalComposer initialConfig={initialConfig}>
<ChatEditorRefPlugin editorRef={editorRef} />
<form
className="border-t bg-background p-3 sm:p-4"
onSubmit={(event) => {
event.preventDefault()
void send()
}}
>
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{files.map((file, index) => (
<span
key={`${file.name}:${file.lastModified}:${file.size}`}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-muted py-1 ps-2 pe-1 text-xs"
>
<span className="max-w-48 truncate">{file.name}</span>
<Button
type="button"
size="icon-xs"
variant="ghost"
onClick={() =>
setFiles((current) =>
current.filter((_, fileIndex) => fileIndex !== index)
)
}
aria-label={t(chatMessages.removeAttachment, {
name: file.name,
})}
>
<XIcon />
</Button>
</span>
))}
</div>
)}
<ChatTextEditor
disabled={editorDisabled}
draft={draft}
onDraftChange={(nextDraft) => {
onDraftChange(nextDraft)
setSendFailed(false)
}}
onSend={() => void send()}
sendOnEnter={sendOnEnter}
placeholder={t(chatMessages.messagePlaceholder)}
/>
<div className="mt-2 flex items-center gap-1">
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
appendFiles(event.target.files)
event.currentTarget.value = ""
}}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="sr-only"
tabIndex={-1}
onChange={(event) => {
appendFiles(event.target.files)
event.currentTarget.value = ""
}}
/>
<ChatEmojiPicker disabled={editorDisabled} />
<Button
type="button"
size="icon-sm"
variant="ghost"
disabled={editorDisabled}
onClick={() => imageInputRef.current?.click()}
aria-label={t(chatMessages.addImage)}
>
<ImagePlusIcon />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
disabled={editorDisabled}
onClick={() => fileInputRef.current?.click()}
aria-label={t(chatMessages.addFiles)}
>
<PaperclipIcon />
</Button>
{sendFailed && (
<span className="ms-2 text-xs text-destructive">
{t(chatMessages.failed)}
</span>
)}
<div className="ms-auto flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={sendOnEnter}
disabled={editorDisabled}
onCheckedChange={onSendOnEnterChange}
/>
{t(chatMessages.sendOnEnter)}
</label>
<Button
type="submit"
size="sm"
disabled={!canSend || disabled || isSending}
>
<SendHorizontalIcon />
{isSending ? t(chatMessages.sending) : t(chatMessages.send)}
</Button>
</div>
</div>
</form>
</LexicalComposer>
)
}
function ChatEditorRefPlugin({
editorRef,
}: {
editorRef: React.RefObject<LexicalEditor | null>
}) {
const [editor] = useLexicalComposerContext()
React.useEffect(() => {
editorRef.current = editor
return () => {
if (editorRef.current === editor) editorRef.current = null
}
}, [editor, editorRef])
return null
}
/**
* Lexical is only the editing surface. The Mini Program customer-service API
* receives Unicode plain text, never HTML or Lexical's serialized state.
*/
function serializeWeChatPlainText(
editor: LexicalEditor | null,
fallback: string
) {
const text =
editor?.getEditorState().read(() => $getRoot().getTextContent()) ?? fallback
return text.replace(/\r\n?/g, "\n").trim()
}
function ChatTextEditor({
disabled,
draft,
onDraftChange,
onSend,
sendOnEnter,
placeholder,
}: {
disabled: boolean
draft: string
onDraftChange: (draft: string) => void
onSend: VoidFunction
sendOnEnter: boolean
placeholder: string
}) {
const submitOnEnter = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
disabled ||
!sendOnEnter ||
event.key !== "Enter" ||
event.nativeEvent.isComposing ||
event.shiftKey
) {
return
}
// Capture the key before Lexical's default Enter handler creates a new
// paragraph. Shift+Enter and IME composition retain their native behavior.
event.preventDefault()
event.stopPropagation()
onSend()
}
return (
<div className="relative min-h-22 rounded-md bg-muted/50">
<PlainTextPlugin
contentEditable={
<ContentEditable
aria-label={placeholder}
aria-placeholder={placeholder}
onKeyDownCapture={submitOnEnter}
placeholder={
<div className="pointer-events-none absolute start-3 top-2 text-sm text-muted-foreground">
{placeholder}
</div>
}
className="min-h-22 px-3 py-2 text-sm outline-none"
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<ChatDraftPlugin draft={draft} onDraftChange={onDraftChange} />
<ChatEditablePlugin disabled={disabled} />
</div>
)
}
function ChatEmojiPicker({ disabled }: { disabled: boolean }) {
const t = useTranslate()
const [editor] = useLexicalComposerContext()
const [open, setOpen] = React.useState(false)
const [recentEmojis, setRecentEmojis] =
React.useState<readonly string[]>(defaultRecentEmojis)
const [activeGroupId, setActiveGroupId] = React.useState<
(typeof customerServiceEmojiGroups)[number]["id"]
>(customerServiceEmojiGroups[0].id)
const activeGroup =
customerServiceEmojiGroups.find((group) => group.id === activeGroupId) ??
customerServiceEmojiGroups[0]
React.useEffect(() => {
try {
const storedValue = window.localStorage.getItem(recentEmojiStorageKey)
if (!storedValue) return
const storedEmojis: unknown = JSON.parse(storedValue)
if (!Array.isArray(storedEmojis)) return
const nextRecentEmojis = storedEmojis
.filter(
(emoji): emoji is string =>
typeof emoji === "string" && supportedEmojis.has(emoji)
)
.slice(0, maximumRecentEmojiCount)
if (nextRecentEmojis.length > 0) setRecentEmojis(nextRecentEmojis)
} catch {
// Local storage is an optional enhancement for the picker.
}
}, [])
const insertEmoji = (emoji: string) => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
selection.insertText(emoji)
} else {
$getRoot().selectEnd().insertText(emoji)
}
})
setRecentEmojis((current) => {
const nextRecentEmojis = [
emoji,
...current.filter((recentEmoji) => recentEmoji !== emoji),
].slice(0, maximumRecentEmojiCount)
try {
window.localStorage.setItem(
recentEmojiStorageKey,
JSON.stringify(nextRecentEmojis)
)
} catch {
// Keep the in-memory history when local storage is unavailable.
}
return nextRecentEmojis
})
editor.focus()
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={t(chatMessages.addEmoji)}
>
<SmileIcon />
</Button>
}
/>
<PopoverContent
side="top"
align="start"
sideOffset={8}
className="max-h-[min(34rem,var(--available-height))] w-fit max-w-[calc(100vw-2rem)] gap-0 p-0"
viewportClassName="gap-0 overflow-hidden"
showArrow
>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
<EmojiSection
emojis={recentEmojis}
onSelect={insertEmoji}
title={t(chatMessages.recentEmojis)}
/>
<EmojiSection
className="mt-5"
emojis={activeGroup.emojis}
onSelect={insertEmoji}
title={t(chatMessages.allEmojis)}
/>
</div>
<div className="flex shrink-0 justify-center border-t bg-popover px-3 py-1">
<div className="grid w-fit grid-cols-8 gap-1" role="tablist">
{customerServiceEmojiGroups.map((group) => (
<Button
key={group.id}
type="button"
variant="ghost"
size="icon"
className={group.id === activeGroup.id ? "bg-muted" : undefined}
aria-label={`${t(chatMessages.addEmoji)}: ${group.icon}`}
aria-selected={group.id === activeGroup.id}
role="tab"
onClick={() => setActiveGroupId(group.id)}
>
<span className="text-2xl" aria-hidden="true">
{group.icon}
</span>
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)
}
function EmojiSection({
className,
emojis,
onSelect,
title,
}: {
className?: string
emojis: readonly string[]
onSelect: (emoji: string) => void
title: string
}) {
return (
<section className={className} aria-label={title}>
<h3 className="mb-3 text-base font-medium">{title}</h3>
<div className="grid w-fit max-w-full auto-rows-max grid-cols-[repeat(8,max-content)] gap-1 sm:grid-cols-[repeat(10,max-content)]">
{emojis.map((emoji) => (
<Button
key={emoji}
type="button"
variant="ghost"
size="icon"
className="text-3xl"
onClick={() => onSelect(emoji)}
aria-label={emoji}
>
{emoji}
</Button>
))}
</div>
</section>
)
}
const maximumRecentEmojiCount = 10
const recentEmojiStorageKey = "workspace.chats.recent-emojis"
const supportedEmojis = new Set<string>(customerServiceEmojis)
const defaultRecentEmojis = customerServiceEmojis.slice(
0,
maximumRecentEmojiCount
)
function ChatDraftPlugin({
draft,
onDraftChange,
}: {
draft: string
onDraftChange: (draft: string) => void
}) {
const [editor] = useLexicalComposerContext()
const lastDraftRef = React.useRef<string | undefined>(undefined)
React.useLayoutEffect(() => {
if (draft === lastDraftRef.current) return
lastDraftRef.current = draft
editor.update(() => {
const root = $getRoot()
if (root.getTextContent() === draft) return
const paragraph = $createParagraphNode()
if (draft) paragraph.append($createTextNode(draft))
root.clear().append(paragraph)
})
}, [draft, editor])
return (
<OnChangePlugin
onChange={(editorState) => {
editorState.read(() => {
const nextDraft = $getRoot().getTextContent()
if (nextDraft === lastDraftRef.current) return
lastDraftRef.current = nextDraft
onDraftChange(nextDraft)
})
}}
/>
)
}
function ChatEditablePlugin({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext()
React.useEffect(() => {
editor.setEditable(!disabled)
}, [disabled, editor])
return null
}
@@ -0,0 +1,134 @@
import { useTranslate } from "@workspace/i18n"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar"
import { Input } from "@workspace/ui/components/input"
import { cn } from "@workspace/ui/lib/utils"
import { SearchIcon } from "lucide-react"
import { chatMessages } from "./messages"
import type { ChatConversation } from "./types"
interface ChatConversationListProps {
activeConversationId?: string
className?: string
conversations: readonly ChatConversation[]
onSearchQueryChange: (query: string) => void
onSelect: (conversation: ChatConversation) => void
searchQuery: string
}
export function ChatConversationList({
activeConversationId,
className,
conversations,
onSearchQueryChange,
onSelect,
searchQuery,
}: ChatConversationListProps) {
const t = useTranslate()
return (
<aside
className={cn("flex min-h-0 flex-col border-e bg-muted/20", className)}
>
<div className="flex h-16 items-center border-b px-3">
<label className="relative min-w-0 flex-1">
<SearchIcon className="pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={t(chatMessages.searchConversations)}
aria-label={t(chatMessages.searchConversations)}
className="h-9 bg-background ps-8 shadow-none"
/>
</label>
</div>
<ul className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{conversations.length === 0 && (
<p className="px-3 py-8 text-center text-sm text-muted-foreground">
{searchQuery
? t(chatMessages.noSearchResults)
: t(chatMessages.emptyConversationsDescription)}
</p>
)}
{conversations.map((conversation) => {
const active = conversation.id === activeConversationId
return (
<li key={conversation.id}>
<button
type="button"
aria-current={active ? "page" : undefined}
aria-label={t(chatMessages.selectConversation, {
title: conversation.title,
})}
onClick={() => onSelect(conversation)}
className={cn(
"group grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3.5 rounded-xl px-3 py-3 text-start transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50",
active &&
"bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
<ConversationAvatar conversation={conversation} />
<span className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] grid-rows-2 gap-x-3 gap-y-1">
<span className="min-w-0 truncate font-medium">
{conversation.title}
</span>
{conversation.lastActivityLabel && (
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
active && "text-primary-foreground/70"
)}
>
{conversation.lastActivityLabel}
</span>
)}
<span
className={cn(
"min-w-0 truncate text-sm text-muted-foreground",
active && "text-primary-foreground/75"
)}
>
{conversation.lastMessage ?? conversation.description}
</span>
{conversation.unreadCount && conversation.unreadCount > 0 ? (
<span
className={cn(
"grid size-5 shrink-0 place-items-center self-end justify-self-end rounded-full bg-primary text-xs font-medium text-primary-foreground",
active && "bg-primary-foreground text-primary"
)}
>
{conversation.unreadCount > 99
? "99+"
: conversation.unreadCount}
</span>
) : null}
</span>
</button>
</li>
)
})}
</ul>
</aside>
)
}
export function ConversationAvatar({
conversation,
size = "default",
}: {
conversation: Pick<ChatConversation, "avatarUrl" | "title">
size?: "default" | "lg" | "sm"
}) {
return (
<Avatar size={size} className="shrink-0">
{conversation.avatarUrl && <AvatarImage src={conversation.avatarUrl} />}
<AvatarFallback>{conversation.title.slice(0, 1)}</AvatarFallback>
</Avatar>
)
}
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react"
import { I18nProvider } from "@workspace/i18n"
import { afterEach, describe, expect, it, vi } from "vitest"
import { ChatMessageList } from "./chat-message-list"
import { messages as englishMessages } from "./locales/en"
import type { ChatConversation } from "./types"
const conversation: ChatConversation = {
id: "general",
messages: [
{
direction: "incoming",
id: "text",
msgtype: "text",
text: { content: "A plain text message" },
},
{
direction: "incoming",
id: "image",
image: { media_id: "image-media-id" },
msgtype: "image",
},
{
direction: "outgoing",
id: "link",
link: {
description: "A concise summary",
thumb_url: "https://example.com/thumbnail.jpg",
title: "Release notes",
url: "https://example.com/release-notes",
},
msgtype: "link",
},
{
direction: "outgoing",
id: "mini-program-page",
miniprogrampage: {
pagepath: "pages/orders/index",
thumb_media_id: "mini-program-thumbnail-id",
title: "Orders",
},
msgtype: "miniprogrampage",
},
],
title: "General",
}
describe("ChatMessageList", () => {
afterEach(cleanup)
it("renders WeChat Mini Program message payloads by msgtype", () => {
const resolveMediaUrl = vi.fn(
(mediaId: string) => `https://media.example/${mediaId}`
)
render(
<I18nProvider locale="en" catalogs={{ en: englishMessages }}>
<ChatMessageList
conversation={conversation}
resolveMediaUrl={resolveMediaUrl}
/>
</I18nProvider>
)
expect(screen.getByText("A plain text message")).toBeTruthy()
expect(screen.getByRole("img", { name: "image-media-id" })).toHaveProperty(
"src",
"https://media.example/image-media-id"
)
expect(
(
screen.getByRole("link", {
name: /release notes/i,
}) as HTMLAnchorElement
).href
).toBe("https://example.com/release-notes")
expect(screen.getByText("Orders")).toBeTruthy()
expect(screen.getByText("pages/orders/index")).toBeTruthy()
expect(resolveMediaUrl).toHaveBeenCalledWith(
"mini-program-thumbnail-id",
conversation.messages[3]
)
})
})
@@ -0,0 +1,348 @@
import { useFormatters, useTranslate } from "@workspace/i18n"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar"
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble"
import { Button } from "@workspace/ui/components/button"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty"
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
} from "@workspace/ui/components/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller"
import { cn } from "@workspace/ui/lib/utils"
import {
AppWindowIcon,
ImageIcon,
LinkIcon,
MessageCircleIcon,
} from "lucide-react"
import { chatMessages } from "./messages"
import type {
ChatConversation,
ChatImageMessage,
ChatMessage,
ChatMessageStatus,
ChatMediaUrlResolver,
ChatMiniProgramPageMessage,
} from "./types"
export interface ChatMessageListProps {
conversation: ChatConversation
hasMoreMessages?: boolean
isLoadingMoreMessages?: boolean
onLoadEarlierMessages?: (conversation: ChatConversation) => void
resolveMediaUrl?: ChatMediaUrlResolver
}
export function ChatMessageList({
conversation,
hasMoreMessages = false,
isLoadingMoreMessages = false,
onLoadEarlierMessages,
resolveMediaUrl,
}: ChatMessageListProps) {
const t = useTranslate()
if (conversation.messages.length === 0) {
return (
<Empty className="min-h-0 flex-1 border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircleIcon />
</EmptyMedia>
<EmptyTitle>{t(chatMessages.emptyMessagesTitle)}</EmptyTitle>
<EmptyDescription>
{t(chatMessages.emptyMessagesDescription)}
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<MessageScrollerProvider
key={conversation.id}
autoScroll
defaultScrollPosition="end"
>
<MessageScroller className="min-h-0 flex-1">
<MessageScrollerViewport>
<MessageScrollerContent className="gap-5 px-4 py-6 sm:px-6">
{hasMoreMessages && onLoadEarlierMessages && (
<MessageScrollerItem
messageId={`${conversation.id}:load-earlier`}
className="flex justify-center"
>
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingMoreMessages}
onClick={() => onLoadEarlierMessages(conversation)}
>
{t(chatMessages.loadEarlier)}
</Button>
</MessageScrollerItem>
)}
{conversation.messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<ChatMessageBubble
message={message}
resolveMediaUrl={resolveMediaUrl}
/>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
)
}
function ChatMessageBubble({
message,
resolveMediaUrl,
}: {
message: ChatMessage
resolveMediaUrl?: ChatMediaUrlResolver
}) {
const t = useTranslate()
const { formatDate } = useFormatters()
const outgoing = message.direction === "outgoing"
const authorName = message.author?.name
const time =
message.timeLabel ?? formatTimestamp(message.createdAt, formatDate)
const status = message.status && t(statusMessages[message.status])
return (
<Message align={outgoing ? "end" : "start"}>
{!outgoing && (
<MessageAvatar>
<Avatar size="sm">
{message.author?.avatarUrl && (
<AvatarImage src={message.author.avatarUrl} />
)}
<AvatarFallback>{authorName?.slice(0, 1) ?? "?"}</AvatarFallback>
</Avatar>
</MessageAvatar>
)}
<MessageContent>
{!outgoing && authorName && <MessageHeader>{authorName}</MessageHeader>}
<Bubble variant={outgoing ? "default" : "muted"}>
<BubbleContent className={cn(message.msgtype !== "text" && "p-0")}>
<MessagePayload
message={message}
resolveMediaUrl={resolveMediaUrl}
/>
</BubbleContent>
</Bubble>
{(time || status) && (
<MessageFooter>
{[time, status].filter(Boolean).join(" · ")}
</MessageFooter>
)}
</MessageContent>
</Message>
)
}
function MessagePayload({
message,
resolveMediaUrl,
}: {
message: ChatMessage
resolveMediaUrl?: ChatMediaUrlResolver
}) {
switch (message.msgtype) {
case "text":
return (
<p className="wrap-break-word whitespace-pre-wrap">
{message.text.content}
</p>
)
case "image":
return (
<ImageMessage message={message} resolveMediaUrl={resolveMediaUrl} />
)
case "link":
return <LinkMessage message={message} />
case "miniprogrampage":
return (
<MiniProgramPageMessage
message={message}
resolveMediaUrl={resolveMediaUrl}
/>
)
}
}
function ImageMessage({
message,
resolveMediaUrl,
}: {
message: ChatImageMessage
resolveMediaUrl?: ChatMediaUrlResolver
}) {
const url = resolveMediaUrl?.(message.image.media_id, message)
if (url) {
return (
<a href={url} target="_blank" rel="noreferrer">
<img
src={url}
alt={message.image.media_id}
className="max-h-80 max-w-full rounded-xl object-cover"
/>
</a>
)
}
return <MediaPlaceholder mediaId={message.image.media_id} icon={ImageIcon} />
}
function LinkMessage({
message,
}: {
message: Extract<ChatMessage, { msgtype: "link" }>
}) {
const { link } = message
return (
<a
href={link.url}
target="_blank"
rel="noreferrer"
className="block min-w-56 overflow-hidden rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-muted"
>
{link.thumb_url && (
<img
src={link.thumb_url}
alt=""
className="aspect-2/1 w-full object-cover"
/>
)}
<span className="flex items-start gap-2 p-3">
<LinkIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block font-medium wrap-break-word">
{link.title}
</span>
<span className="mt-1 block text-xs wrap-break-word text-muted-foreground">
{link.description}
</span>
</span>
</span>
</a>
)
}
function MiniProgramPageMessage({
message,
resolveMediaUrl,
}: {
message: ChatMiniProgramPageMessage
resolveMediaUrl?: ChatMediaUrlResolver
}) {
const { miniprogrampage } = message
const thumbnailUrl = resolveMediaUrl?.(
miniprogrampage.thumb_media_id,
message
)
return (
<div className="flex min-w-56 items-center gap-3 rounded-xl border border-border bg-background p-3 text-foreground">
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className="size-12 rounded-lg object-cover"
/>
) : (
<MediaPlaceholder
mediaId={miniprogrampage.thumb_media_id}
icon={AppWindowIcon}
className="size-12 shrink-0 px-1 text-[0.55rem]"
/>
)}
<span className="min-w-0">
<span className="block font-medium wrap-break-word">
{miniprogrampage.title}
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">
{miniprogrampage.pagepath}
</span>
</span>
</div>
)
}
function MediaPlaceholder({
className,
icon: Icon,
mediaId,
}: {
className?: string
icon: typeof ImageIcon
mediaId: string
}) {
return (
<span
className={cn(
"grid aspect-4/3 min-w-48 place-items-center gap-1 rounded-xl bg-muted p-3 text-center text-xs text-muted-foreground",
className
)}
title={mediaId}
>
<Icon className="size-5" />
<span className="max-w-full truncate">{mediaId}</span>
</span>
)
}
const statusMessages: Record<
ChatMessageStatus,
(typeof chatMessages)[keyof typeof chatMessages]
> = {
delivered: chatMessages.delivered,
failed: chatMessages.failed,
read: chatMessages.read,
sending: chatMessages.sendingStatus,
sent: chatMessages.sent,
}
function formatTimestamp(
value: string | undefined,
formatDate: (
value: Date | number,
options?: Intl.DateTimeFormatOptions
) => string
) {
if (!value) return undefined
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return formatDate(date, { hour: "2-digit", minute: "2-digit" })
}
@@ -0,0 +1,220 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { I18nProvider } from "@workspace/i18n"
import { afterEach, describe, expect, it, vi } from "vitest"
import { ChatWorkspace } from "./chat-workspace"
import { messages as englishMessages } from "./locales/en"
import type { ChatConversation, ChatSendHandler } from "./types"
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: false,
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
}))
)
vi.stubGlobal(
"ResizeObserver",
class {
disconnect = vi.fn()
observe = vi.fn()
unobserve = vi.fn()
}
)
const conversations: readonly ChatConversation[] = [
{
avatarUrl: "/ada.png",
id: "general",
lastActivityLabel: "09:00",
lastMessage: "Welcome to the team",
messages: [
{
author: { id: "ada", name: "Ada Lovelace" },
direction: "incoming",
id: "message-1",
msgtype: "text",
text: { content: "Welcome to the team" },
timeLabel: "09:00",
},
],
title: "General",
},
{
id: "support",
lastMessage: "How can we help?",
messages: [],
title: "Support",
unreadCount: 2,
},
]
function renderWorkspace({
draft,
onSend = vi.fn(),
}: {
draft?: string
onSend?: ChatSendHandler
} = {}) {
return render(
<I18nProvider locale="en" catalogs={{ en: englishMessages }}>
<ChatWorkspace
conversations={conversations}
draft={draft}
onSend={onSend}
/>
</I18nProvider>
)
}
describe("ChatWorkspace", () => {
afterEach(cleanup)
it("keeps the disabled send button visible for an empty draft", () => {
renderWorkspace()
expect(
screen.getByRole("button", { name: "Send" }).getAttribute("disabled")
).not.toBeNull()
})
it("filters and selects host-provided conversations", () => {
renderWorkspace()
const search = screen.getByPlaceholderText("Search conversations")
fireEvent.change(search, { target: { value: "support" } })
expect(
screen.getByRole("button", { name: "Select conversation Support" })
).toBeTruthy()
expect(
screen.queryByRole("button", { name: "Select conversation General" })
).toBeNull()
fireEvent.click(
screen.getByRole("button", { name: "Select conversation Support" })
)
expect(screen.getByText("No messages yet")).toBeTruthy()
})
it("returns the composed text and original files through onSend", async () => {
const onSend = vi.fn(async () => undefined)
const { container } = renderWorkspace({
draft: "Please review this.",
onSend,
})
const attachment = new File(["report"], "report.pdf", {
type: "application/pdf",
})
const fileInputs =
container.querySelectorAll<HTMLInputElement>('input[type="file"]')
fireEvent.change(fileInputs[1]!, { target: { files: [attachment] } })
fireEvent.click(screen.getByRole("button", { name: "Send" }))
await waitFor(() => expect(onSend).toHaveBeenCalledOnce())
expect(onSend).toHaveBeenCalledWith({
conversation: conversations[0],
files: [attachment],
text: "Please review this.",
})
})
it("sends the draft when Enter is pressed and keeps Shift+Enter for a newline", async () => {
const onSend = vi.fn(async () => undefined)
renderWorkspace({ draft: "Send from Enter", onSend })
const editor = screen.getByLabelText("Write a message…")
fireEvent.keyDown(editor, { key: "Enter", code: "Enter" })
await waitFor(() => expect(onSend).toHaveBeenCalledOnce())
expect(onSend).toHaveBeenCalledWith({
conversation: conversations[0],
files: [],
text: "Send from Enter",
})
fireEvent.keyDown(editor, {
key: "Enter",
code: "Enter",
shiftKey: true,
})
expect(onSend).toHaveBeenCalledOnce()
})
it("lets people disable Enter-to-send", async () => {
const onSend = vi.fn(async () => undefined)
renderWorkspace({ draft: "Keep editing", onSend })
fireEvent.click(
screen.getByRole("checkbox", { name: "Press Enter to send" })
)
fireEvent.keyDown(screen.getByLabelText("Write a message…"), {
key: "Enter",
code: "Enter",
})
expect(onSend).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "Send" }))
await waitFor(() => expect(onSend).toHaveBeenCalledOnce())
})
it("serializes a Lexical emoji draft as Unicode plain text", async () => {
const onSend = vi.fn(async () => undefined)
renderWorkspace({ onSend })
fireEvent.click(screen.getByRole("button", { name: "Add emoji" }))
await waitFor(() =>
expect(
document.querySelector('[data-slot="popover-viewport"]')
).toBeTruthy()
)
fireEvent.click((await screen.findAllByRole("button", { name: "😀" }))[0])
await waitFor(() =>
expect(screen.getByLabelText("Write a message…").textContent).toBe("😀")
)
expect(
screen.getAllByRole("button", { name: "😁" }).length
).toBeGreaterThan(0)
fireEvent.click(screen.getByRole("button", { name: "Send" }))
await waitFor(() => expect(onSend).toHaveBeenCalledOnce())
expect(onSend).toHaveBeenCalledWith({
conversation: conversations[0],
files: [],
text: "😀",
})
})
it("uses a Lexical contenteditable for controlled drafts", async () => {
const { rerender } = renderWorkspace()
const editor = screen.getByLabelText("Write a message…")
expect(editor.getAttribute("contenteditable")).toBe("true")
rerender(
<I18nProvider locale="en" catalogs={{ en: englishMessages }}>
<ChatWorkspace conversations={conversations} draft="Lexical draft" />
</I18nProvider>
)
await waitFor(() => expect(editor.textContent).toBe("Lexical draft"))
})
})
@@ -0,0 +1,304 @@
import * as React from "react"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty"
import { useIsMobile } from "@workspace/ui/hooks/use-breakpoint"
import { cn } from "@workspace/ui/lib/utils"
import { ArrowLeftIcon, InfoIcon, MessageCircleIcon } from "lucide-react"
import { ChatComposer } from "./chat-composer"
import {
ChatConversationList,
ConversationAvatar,
} from "./chat-conversation-list"
import { ChatMessageList } from "./chat-message-list"
import { chatMessages } from "./messages"
import type {
ChatConversation,
ChatMediaUrlResolver,
ChatSendHandler,
} from "./types"
import { filterChatConversations, getInitialConversationId } from "./utils"
export interface ChatWorkspaceProps extends Omit<
React.ComponentProps<"section">,
"children"
> {
activeConversationId?: string
conversations: readonly ChatConversation[]
defaultActiveConversationId?: string
/** Initial value for the uncontrolled Enter-to-send preference. */
defaultSendOnEnter?: boolean
disabled?: boolean
draft?: string
/** The workspace height. Defaults to `maxHeight` so internal panes can scroll. */
height?: React.CSSProperties["height"]
hasMoreMessages?: boolean
isLoadingMoreMessages?: boolean
/** Caps the workspace while allowing the message list to scroll. */
maxHeight?: React.CSSProperties["maxHeight"]
/** Ensures a usable conversation area before the viewport cap is reached. */
minHeight?: React.CSSProperties["minHeight"]
onActiveConversationChange?: (conversation: ChatConversation) => void
onConversationInfo?: (conversation: ChatConversation) => void
onDraftChange?: (event: {
conversation: ChatConversation
draft: string
}) => void
onLoadEarlierMessages?: (conversation: ChatConversation) => void
onSearchQueryChange?: (query: string) => void
onSend?: ChatSendHandler
/** Called when the user changes whether Enter submits the draft. */
onSendOnEnterChange?: (sendOnEnter: boolean) => void
resolveMediaUrl?: ChatMediaUrlResolver
searchQuery?: string
/** Controls whether Enter submits a message. Shift+Enter always inserts a line break. */
sendOnEnter?: boolean
}
/**
* A standalone, controlled chat workspace. It displays host-provided
* one-to-one customer-service conversations and emits selection, draft, and
* send events without assuming a transport, storage layer, or upload service.
*/
export function ChatWorkspace({
activeConversationId,
className,
conversations,
defaultActiveConversationId,
defaultSendOnEnter = true,
disabled,
draft,
hasMoreMessages,
isLoadingMoreMessages,
maxHeight = "min(60rem, calc(100dvh - 8rem))",
height = maxHeight,
minHeight = "min(36rem, calc(100dvh - 8rem))",
onActiveConversationChange,
onConversationInfo,
onDraftChange,
onLoadEarlierMessages,
onSearchQueryChange,
onSend,
onSendOnEnterChange,
resolveMediaUrl,
searchQuery,
sendOnEnter,
style,
...props
}: ChatWorkspaceProps) {
const t = useTranslate()
const isMobile = useIsMobile()
const [uncontrolledActiveId, setUncontrolledActiveId] = React.useState(() =>
getInitialConversationId(conversations, defaultActiveConversationId)
)
const [uncontrolledDraft, setUncontrolledDraft] = React.useState("")
const [uncontrolledSearchQuery, setUncontrolledSearchQuery] =
React.useState("")
const [uncontrolledSendOnEnter, setUncontrolledSendOnEnter] =
React.useState(defaultSendOnEnter)
const [mobileConversationOpen, setMobileConversationOpen] = React.useState(
Boolean(activeConversationId ?? defaultActiveConversationId)
)
const resolvedActiveId =
activeConversationId ??
getInitialConversationId(conversations, uncontrolledActiveId)
const activeConversation = conversations.find(
(conversation) => conversation.id === resolvedActiveId
)
const resolvedDraft = draft ?? uncontrolledDraft
const resolvedSearchQuery = searchQuery ?? uncontrolledSearchQuery
const resolvedSendOnEnter = sendOnEnter ?? uncontrolledSendOnEnter
const filteredConversations = filterChatConversations(
conversations,
resolvedSearchQuery
)
React.useEffect(() => {
if (activeConversationId === undefined) {
setUncontrolledActiveId((current) =>
getInitialConversationId(conversations, current)
)
}
}, [activeConversationId, conversations])
React.useEffect(() => {
if (draft === undefined) setUncontrolledDraft("")
}, [draft, resolvedActiveId])
React.useEffect(() => {
if (isMobile && activeConversationId) setMobileConversationOpen(true)
}, [activeConversationId, isMobile])
const selectConversation = (conversation: ChatConversation) => {
if (activeConversationId === undefined)
setUncontrolledActiveId(conversation.id)
onActiveConversationChange?.(conversation)
setMobileConversationOpen(true)
}
const setDraft = (nextDraft: string) => {
if (draft === undefined) setUncontrolledDraft(nextDraft)
if (activeConversation)
onDraftChange?.({ conversation: activeConversation, draft: nextDraft })
}
const setSearchQuery = (nextQuery: string) => {
if (searchQuery === undefined) setUncontrolledSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery)
}
const setSendOnEnter = (nextSendOnEnter: boolean) => {
if (sendOnEnter === undefined) setUncontrolledSendOnEnter(nextSendOnEnter)
onSendOnEnterChange?.(nextSendOnEnter)
}
const showConversation = !isMobile || mobileConversationOpen
return (
<section
className={cn(
"grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden rounded-xl border bg-background shadow-xs md:grid-cols-[minmax(16rem,22rem)_minmax(0,1fr)]",
className
)}
style={{ height, maxHeight, minHeight, ...style }}
{...props}
>
<ChatConversationList
className={cn(showConversation && "mobile:hidden")}
conversations={filteredConversations}
activeConversationId={activeConversation?.id}
onSearchQueryChange={setSearchQuery}
onSelect={selectConversation}
searchQuery={resolvedSearchQuery}
/>
{showConversation && (
<div className="flex min-h-0 flex-col mobile:col-span-full">
{activeConversation ? (
<>
<ChatWorkspaceHeader
conversation={activeConversation}
onBack={
isMobile ? () => setMobileConversationOpen(false) : undefined
}
onInfo={
onConversationInfo
? () => onConversationInfo(activeConversation)
: undefined
}
/>
<ChatMessageList
conversation={activeConversation}
hasMoreMessages={hasMoreMessages}
isLoadingMoreMessages={isLoadingMoreMessages}
onLoadEarlierMessages={onLoadEarlierMessages}
resolveMediaUrl={resolveMediaUrl}
/>
<ChatComposer
key={activeConversation.id}
conversation={activeConversation}
draft={resolvedDraft}
disabled={disabled}
onDraftChange={setDraft}
onSend={onSend}
onSendOnEnterChange={setSendOnEnter}
sendOnEnter={resolvedSendOnEnter}
/>
</>
) : conversations.length === 0 ? (
<EmptyChatState
title={t(chatMessages.emptyConversationsTitle)}
description={t(chatMessages.emptyConversationsDescription)}
/>
) : (
<EmptyChatState
title={t(chatMessages.title)}
description={t(chatMessages.selectConversationDescription)}
/>
)}
</div>
)}
{!showConversation && isMobile && (
<div className="sr-only">{t(chatMessages.backToConversations)}</div>
)}
</section>
)
}
function ChatWorkspaceHeader({
conversation,
onBack,
onInfo,
}: {
conversation: ChatConversation
onBack?: VoidFunction
onInfo?: VoidFunction
}) {
const t = useTranslate()
return (
<header className="flex min-h-16 items-center gap-3 border-b px-4 sm:px-6">
{onBack && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="-ms-2 mobile:inline-flex"
onClick={onBack}
aria-label={t(chatMessages.backToConversations)}
>
<ArrowLeftIcon />
</Button>
)}
<ConversationAvatar conversation={conversation} size="sm" />
<div className="min-w-0 flex-1">
<h2 className="truncate font-heading font-medium">
{conversation.title}
</h2>
{conversation.description && (
<p className="truncate text-xs text-muted-foreground">
{conversation.description}
</p>
)}
</div>
{onInfo && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onInfo}
aria-label={t(chatMessages.conversationDetails)}
>
<InfoIcon />
</Button>
)}
</header>
)
}
function EmptyChatState({
description,
title,
}: {
description: React.ReactNode
title: React.ReactNode
}) {
return (
<Empty className="min-h-0 flex-1 border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircleIcon />
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Cross-platform Unicode emoji for Mini Program customer-service text.
*
* Avoid platform-private stickers, flags, skin-tone variants, and complex ZWJ
* sequences. These standard emoji are rendered by the native iOS and Android
* emoji fonts while retaining the same text payload in transit.
*/
export const customerServiceEmojis = [
"😀",
"😃",
"😄",
"😁",
"😆",
"😅",
"😂",
"😊",
"😇",
"🙂",
"🙃",
"😉",
"😌",
"😍",
"😘",
"😗",
"😙",
"😚",
"😋",
"😛",
"😜",
"😝",
"🤗",
"🤔",
"😐",
"😑",
"😶",
"🙄",
"😏",
"😣",
"😥",
"😮",
"😯",
"😪",
"😫",
"😴",
"🤓",
"😎",
"😕",
"😟",
"🙁",
"☹️",
"😲",
"😖",
"😞",
"😤",
"😢",
"😭",
"😦",
"😧",
"😨",
"😩",
"😬",
"😰",
"😱",
"😳",
"😵",
"😡",
"😠",
"👋",
"🖐️",
"✋",
"🖖",
"👌",
"✌️",
"🤞",
"🤘",
"🤙",
"👈",
"👉",
"👆",
"👇",
"☝️",
"👍",
"👎",
"✊",
"👊",
"👏",
"🙌",
"👐",
"🤝",
"🙏",
"💪",
"👀",
"👂",
"👃",
"👄",
"💋",
"👶",
"👦",
"👧",
"👨",
"👩",
"👴",
"👵",
"🙍",
"🙎",
"🙅",
"🙆",
"💁",
"🙋",
"🙇",
"👮",
"👷",
"💂",
"👳",
"🐶",
"🐱",
"🐭",
"🐹",
"🐰",
"🐻",
"🐼",
"🐨",
"🐯",
"🦁",
"🐮",
"🐷",
"🐸",
"🐵",
"🐔",
"🐧",
"🐦",
"🐤",
"🐝",
"🐛",
"🐌",
"🐞",
"🐙",
"🐟",
"🐬",
"🐳",
"🐊",
"🐢",
"🍏",
"🍎",
"🍐",
"🍊",
"🍋",
"🍌",
"🍉",
"🍇",
"🍓",
"🍈",
"🍒",
"🍑",
"🍍",
"🍅",
"🍆",
"🍞",
"🧀",
"🍔",
"🍟",
"🍕",
"🌭",
"🌮",
"🍣",
"🍱",
"🍜",
"🍙",
"🍚",
"🍛",
"🍦",
"🍰",
"🎂",
"🍭",
"🍬",
"🍫",
"🍿",
"☕",
"🍵",
"🍺",
"🍻",
"🍷",
"⚽",
"🏀",
"🏈",
"⚾",
"🎾",
"🎱",
"🏆",
"🚗",
"🚕",
"🚌",
"🚑",
"🚒",
"🚲",
"✈️",
"🚀",
"⛵",
"💡",
"📱",
"💻",
"🖥️",
"⌨️",
"🖱️",
"🖨️",
"📷",
"🎥",
"📺",
"📻",
"⏰",
"🔔",
"🎁",
"📦",
"✉️",
"📧",
"📌",
"📍",
"✂️",
"🔒",
"🔑",
"🔨",
"❤️",
"💛",
"💚",
"💙",
"💜",
"🖤",
"💔",
"❣️",
"💕",
"💞",
"💓",
"💗",
"💖",
"💘",
"💝",
"✅",
"☑️",
"✔️",
"✖️",
"❌",
"❗",
"❓",
"⁉️",
"‼️",
"⚠️",
"♻️",
"🚫",
"💯",
"💢",
"💥",
"💦",
"💤",
] as const
export const customerServiceEmojiGroups = [
{
emojis: customerServiceEmojis.slice(0, 59),
icon: "😀",
id: "smileys",
},
{
emojis: customerServiceEmojis.slice(59, 88),
icon: "👋",
id: "gestures",
},
{
emojis: customerServiceEmojis.slice(88, 106),
icon: "👤",
id: "people",
},
{
emojis: customerServiceEmojis.slice(106, 134),
icon: "🐶",
id: "animals",
},
{
emojis: customerServiceEmojis.slice(134, 174),
icon: "🍔",
id: "food",
},
{
emojis: customerServiceEmojis.slice(174, 190),
icon: "🚗",
id: "travel",
},
{
emojis: customerServiceEmojis.slice(190, 213),
icon: "💡",
id: "objects",
},
{
emojis: customerServiceEmojis.slice(213),
icon: "❤️",
id: "symbols",
},
] as const
+22 -1
View File
@@ -1,4 +1,25 @@
export { ChatPopover } from "./chat-popover"
export type { ChatPopoverProps } from "./chat-popover"
export { ChatPopoverTrigger } from "./chat-popover-trigger"
export type { ChatThread } from "./types"
export { ChatWorkspace, type ChatWorkspaceProps } from "./chat-workspace"
export type {
ChatConversation,
ChatImageMessage,
ChatLinkMessage,
ChatMessage,
ChatMediaUrlResolver,
ChatMessageStatus,
ChatMiniProgramPageMessage,
ChatParticipant,
ChatSendEvent,
ChatSendHandler,
ChatTextMessage,
ChatThread,
WeChatMiniProgramCustomerServiceImageMessage,
WeChatMiniProgramCustomerServiceLinkMessage,
WeChatMiniProgramCustomerServiceMessage,
WeChatMiniProgramCustomerServiceMessageRequest,
WeChatMiniProgramCustomerServicePageMessage,
WeChatMiniProgramCustomerServiceTextMessage,
} from "./types"
export { filterChatConversations, getInitialConversationId } from "./utils"
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.chats.actions.addFiles": "Dateien hinzufügen",
"blocks.chats.actions.addEmoji": "Emoji hinzufügen",
"blocks.chats.actions.addImage": "Bild hinzufügen",
"blocks.chats.actions.backToConversations": "Zurück zu Unterhaltungen",
"blocks.chats.actions.conversationDetails": "Unterhaltungsdetails",
"blocks.chats.actions.loadEarlier": "Frühere Nachrichten laden",
"blocks.chats.actions.removeAttachment": "{name} entfernen",
"blocks.chats.actions.retry": "Erneut versuchen",
"blocks.chats.actions.send": "Senden",
"blocks.chats.actions.sendOnEnter": "Mit Enter senden",
"blocks.chats.actions.sending": "Wird gesendet…",
"blocks.chats.emptyConversations.description":
"Starte eine Unterhaltung, damit sie hier erscheint.",
"blocks.chats.emptyConversations.title": "Keine Unterhaltungen",
"blocks.chats.emptyMessages.description":
"Nachrichten dieser Unterhaltung werden hier angezeigt.",
"blocks.chats.emptyMessages.title": "Noch keine Nachrichten",
"blocks.chats.emptySearch": "Keine Unterhaltungen entsprechen deiner Suche.",
"blocks.chats.emojiPicker.allEmojis": "Alle Emojis",
"blocks.chats.emojiPicker.recentEmojis": "Kürzlich verwendet",
"blocks.chats.messagePlaceholder": "Nachricht schreiben…",
"blocks.chats.messageStatus.delivered": "Zugestellt",
"blocks.chats.messageStatus.failed": "Nicht gesendet",
"blocks.chats.messageStatus.read": "Gelesen",
"blocks.chats.messageStatus.sending": "Wird gesendet",
"blocks.chats.messageStatus.sent": "Gesendet",
"blocks.chats.search": "Unterhaltungen suchen",
"blocks.chats.selectConversation": "Unterhaltung {title} auswählen",
"blocks.chats.selectConversation.description":
"Wähle eine Unterhaltung aus der Liste, um ihre Nachrichten zu lesen.",
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.chats.actions.addFiles": "Add files",
"blocks.chats.actions.addEmoji": "Add emoji",
"blocks.chats.actions.addImage": "Add image",
"blocks.chats.actions.backToConversations": "Back to conversations",
"blocks.chats.actions.conversationDetails": "Conversation details",
"blocks.chats.actions.loadEarlier": "Load earlier messages",
"blocks.chats.actions.removeAttachment": "Remove {name}",
"blocks.chats.actions.retry": "Try again",
"blocks.chats.actions.send": "Send",
"blocks.chats.actions.sendOnEnter": "Press Enter to send",
"blocks.chats.actions.sending": "Sending…",
"blocks.chats.emptyConversations.description":
"Start a conversation to see it here.",
"blocks.chats.emptyConversations.title": "No conversations",
"blocks.chats.emptyMessages.description":
"Messages in this conversation will appear here.",
"blocks.chats.emptyMessages.title": "No messages yet",
"blocks.chats.emptySearch": "No conversations match your search.",
"blocks.chats.emojiPicker.allEmojis": "All emojis",
"blocks.chats.emojiPicker.recentEmojis": "Recently used",
"blocks.chats.messagePlaceholder": "Write a message…",
"blocks.chats.messageStatus.delivered": "Delivered",
"blocks.chats.messageStatus.failed": "Not sent",
"blocks.chats.messageStatus.read": "Read",
"blocks.chats.messageStatus.sending": "Sending",
"blocks.chats.messageStatus.sent": "Sent",
"blocks.chats.search": "Search conversations",
"blocks.chats.selectConversation": "Select conversation {title}",
"blocks.chats.selectConversation.description":
"Choose a conversation from the list to read its messages.",
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,36 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.chats.actions.addFiles": "Añadir archivos",
"blocks.chats.actions.addEmoji": "Añadir emoji",
"blocks.chats.actions.addImage": "Añadir imagen",
"blocks.chats.actions.backToConversations": "Volver a conversaciones",
"blocks.chats.actions.conversationDetails": "Detalles de la conversación",
"blocks.chats.actions.loadEarlier": "Cargar mensajes anteriores",
"blocks.chats.actions.removeAttachment": "Quitar {name}",
"blocks.chats.actions.retry": "Intentar de nuevo",
"blocks.chats.actions.send": "Enviar",
"blocks.chats.actions.sendOnEnter": "Enviar con Intro",
"blocks.chats.actions.sending": "Enviando…",
"blocks.chats.emptyConversations.description":
"Inicia una conversación para verla aquí.",
"blocks.chats.emptyConversations.title": "No hay conversaciones",
"blocks.chats.emptyMessages.description":
"Los mensajes de esta conversación aparecerán aquí.",
"blocks.chats.emptyMessages.title": "Aún no hay mensajes",
"blocks.chats.emptySearch":
"No hay conversaciones que coincidan con tu búsqueda.",
"blocks.chats.emojiPicker.allEmojis": "Todos los emojis",
"blocks.chats.emojiPicker.recentEmojis": "Usados recientemente",
"blocks.chats.messagePlaceholder": "Escribe un mensaje…",
"blocks.chats.messageStatus.delivered": "Entregado",
"blocks.chats.messageStatus.failed": "No enviado",
"blocks.chats.messageStatus.read": "Leído",
"blocks.chats.messageStatus.sending": "Enviando",
"blocks.chats.messageStatus.sent": "Enviado",
"blocks.chats.search": "Buscar conversaciones",
"blocks.chats.selectConversation": "Seleccionar conversación {title}",
"blocks.chats.selectConversation.description":
"Elige una conversación de la lista para leer sus mensajes.",
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,36 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.chats.actions.addFiles": "Ajouter des fichiers",
"blocks.chats.actions.addEmoji": "Ajouter un emoji",
"blocks.chats.actions.addImage": "Ajouter une image",
"blocks.chats.actions.backToConversations": "Retour aux discussions",
"blocks.chats.actions.conversationDetails": "Détails de la discussion",
"blocks.chats.actions.loadEarlier": "Charger les messages précédents",
"blocks.chats.actions.removeAttachment": "Retirer {name}",
"blocks.chats.actions.retry": "Réessayer",
"blocks.chats.actions.send": "Envoyer",
"blocks.chats.actions.sendOnEnter": "Envoyer avec Entrée",
"blocks.chats.actions.sending": "Envoi…",
"blocks.chats.emptyConversations.description":
"Démarrez une discussion pour la voir ici.",
"blocks.chats.emptyConversations.title": "Aucune discussion",
"blocks.chats.emptyMessages.description":
"Les messages de cette discussion apparaîtront ici.",
"blocks.chats.emptyMessages.title": "Pas encore de messages",
"blocks.chats.emptySearch":
"Aucune discussion ne correspond à votre recherche.",
"blocks.chats.emojiPicker.allEmojis": "Tous les emojis",
"blocks.chats.emojiPicker.recentEmojis": "Récemment utilisés",
"blocks.chats.messagePlaceholder": "Écrivez un message…",
"blocks.chats.messageStatus.delivered": "Distribué",
"blocks.chats.messageStatus.failed": "Non envoyé",
"blocks.chats.messageStatus.read": "Lu",
"blocks.chats.messageStatus.sending": "Envoi",
"blocks.chats.messageStatus.sent": "Envoyé",
"blocks.chats.search": "Rechercher des discussions",
"blocks.chats.selectConversation": "Sélectionner la discussion {title}",
"blocks.chats.selectConversation.description":
"Choisissez une discussion dans la liste pour lire ses messages.",
"blocks.chats.title": "Discussions",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.chats.actions.addFiles": "ファイルを追加",
"blocks.chats.actions.addEmoji": "絵文字を追加",
"blocks.chats.actions.addImage": "画像を追加",
"blocks.chats.actions.backToConversations": "会話一覧に戻る",
"blocks.chats.actions.conversationDetails": "会話の詳細",
"blocks.chats.actions.loadEarlier": "以前のメッセージを読み込む",
"blocks.chats.actions.removeAttachment": "{name} を削除",
"blocks.chats.actions.retry": "再試行",
"blocks.chats.actions.send": "送信",
"blocks.chats.actions.sendOnEnter": "Enter キーで送信",
"blocks.chats.actions.sending": "送信中…",
"blocks.chats.emptyConversations.description":
"会話を始めると、ここに表示されます。",
"blocks.chats.emptyConversations.title": "会話はありません",
"blocks.chats.emptyMessages.description":
"この会話のメッセージはここに表示されます。",
"blocks.chats.emptyMessages.title": "メッセージはまだありません",
"blocks.chats.emptySearch": "検索に一致する会話はありません。",
"blocks.chats.emojiPicker.allEmojis": "すべての絵文字",
"blocks.chats.emojiPicker.recentEmojis": "最近使用した絵文字",
"blocks.chats.messagePlaceholder": "メッセージを入力…",
"blocks.chats.messageStatus.delivered": "配信済み",
"blocks.chats.messageStatus.failed": "未送信",
"blocks.chats.messageStatus.read": "既読",
"blocks.chats.messageStatus.sending": "送信中",
"blocks.chats.messageStatus.sent": "送信済み",
"blocks.chats.search": "会話を検索",
"blocks.chats.selectConversation": "会話 {title} を選択",
"blocks.chats.selectConversation.description":
"一覧から会話を選択してメッセージを読みます。",
"blocks.chats.title": "チャット",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.chats.actions.addFiles": "파일 추가",
"blocks.chats.actions.addEmoji": "이모지 추가",
"blocks.chats.actions.addImage": "이미지 추가",
"blocks.chats.actions.backToConversations": "대화 목록으로 돌아가기",
"blocks.chats.actions.conversationDetails": "대화 세부 정보",
"blocks.chats.actions.loadEarlier": "이전 메시지 불러오기",
"blocks.chats.actions.removeAttachment": "{name} 제거",
"blocks.chats.actions.retry": "다시 시도",
"blocks.chats.actions.send": "보내기",
"blocks.chats.actions.sendOnEnter": "Enter 키로 전송",
"blocks.chats.actions.sending": "보내는 중…",
"blocks.chats.emptyConversations.description":
"대화를 시작하면 여기에 표시됩니다.",
"blocks.chats.emptyConversations.title": "대화가 없습니다",
"blocks.chats.emptyMessages.description":
"이 대화의 메시지가 여기에 표시됩니다.",
"blocks.chats.emptyMessages.title": "아직 메시지가 없습니다",
"blocks.chats.emptySearch": "검색과 일치하는 대화가 없습니다.",
"blocks.chats.emojiPicker.allEmojis": "모든 이모지",
"blocks.chats.emojiPicker.recentEmojis": "최근 사용",
"blocks.chats.messagePlaceholder": "메시지 작성…",
"blocks.chats.messageStatus.delivered": "전달됨",
"blocks.chats.messageStatus.failed": "전송되지 않음",
"blocks.chats.messageStatus.read": "읽음",
"blocks.chats.messageStatus.sending": "보내는 중",
"blocks.chats.messageStatus.sent": "보냄",
"blocks.chats.search": "대화 검색",
"blocks.chats.selectConversation": "대화 {title} 선택",
"blocks.chats.selectConversation.description":
"목록에서 대화를 선택하여 메시지를 읽으세요.",
"blocks.chats.title": "채팅",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,32 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.chats.actions.addFiles": "添加文件",
"blocks.chats.actions.addEmoji": "添加表情",
"blocks.chats.actions.addImage": "添加图片",
"blocks.chats.actions.backToConversations": "返回会话列表",
"blocks.chats.actions.conversationDetails": "会话详情",
"blocks.chats.actions.loadEarlier": "加载更早的消息",
"blocks.chats.actions.removeAttachment": "移除 {name}",
"blocks.chats.actions.retry": "重试",
"blocks.chats.actions.send": "发送",
"blocks.chats.actions.sendOnEnter": "按 Enter 发送",
"blocks.chats.actions.sending": "正在发送…",
"blocks.chats.emptyConversations.description": "新建会话后会显示在这里。",
"blocks.chats.emptyConversations.title": "暂无会话",
"blocks.chats.emptyMessages.description": "此会话中的消息会显示在这里。",
"blocks.chats.emptyMessages.title": "暂无消息",
"blocks.chats.emptySearch": "没有匹配搜索条件的会话。",
"blocks.chats.emojiPicker.allEmojis": "所有表情",
"blocks.chats.emojiPicker.recentEmojis": "最近使用",
"blocks.chats.messagePlaceholder": "输入消息…",
"blocks.chats.messageStatus.delivered": "已送达",
"blocks.chats.messageStatus.failed": "发送失败",
"blocks.chats.messageStatus.read": "已读",
"blocks.chats.messageStatus.sending": "发送中",
"blocks.chats.messageStatus.sent": "已发送",
"blocks.chats.search": "搜索会话",
"blocks.chats.selectConversation": "选择会话 {title}",
"blocks.chats.selectConversation.description": "从列表中选择会话以查看消息。",
"blocks.chats.title": "聊天",
} as const satisfies ChatMessageCatalog
@@ -3,5 +3,32 @@ import type { ChatMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.chats.actions.addFiles": "新增檔案",
"blocks.chats.actions.addEmoji": "新增表情",
"blocks.chats.actions.addImage": "新增圖片",
"blocks.chats.actions.backToConversations": "返回對話列表",
"blocks.chats.actions.conversationDetails": "對話詳細資料",
"blocks.chats.actions.loadEarlier": "載入較早訊息",
"blocks.chats.actions.removeAttachment": "移除 {name}",
"blocks.chats.actions.retry": "再試一次",
"blocks.chats.actions.send": "傳送",
"blocks.chats.actions.sendOnEnter": "按 Enter 傳送",
"blocks.chats.actions.sending": "傳送中…",
"blocks.chats.emptyConversations.description": "開始對話後會顯示在這裡。",
"blocks.chats.emptyConversations.title": "沒有對話",
"blocks.chats.emptyMessages.description": "此對話的訊息會顯示在這裡。",
"blocks.chats.emptyMessages.title": "尚無訊息",
"blocks.chats.emptySearch": "沒有符合搜尋條件的對話。",
"blocks.chats.emojiPicker.allEmojis": "所有表情",
"blocks.chats.emojiPicker.recentEmojis": "最近使用",
"blocks.chats.messagePlaceholder": "輸入訊息…",
"blocks.chats.messageStatus.delivered": "已送達",
"blocks.chats.messageStatus.failed": "未傳送",
"blocks.chats.messageStatus.read": "已讀",
"blocks.chats.messageStatus.sending": "傳送中",
"blocks.chats.messageStatus.sent": "已傳送",
"blocks.chats.search": "搜尋對話",
"blocks.chats.selectConversation": "選取對話 {title}",
"blocks.chats.selectConversation.description": "從列表中選取對話以閱讀訊息。",
"blocks.chats.title": "聊天",
} as const satisfies ChatMessageCatalog
@@ -1,6 +1,114 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const chatMessages = {
addFiles: /* i18n */ {
id: "blocks.chats.actions.addFiles",
message: "Add files",
},
addImage: /* i18n */ {
id: "blocks.chats.actions.addImage",
message: "Add image",
},
allEmojis: /* i18n */ {
id: "blocks.chats.emojiPicker.allEmojis",
message: "All emojis",
},
addEmoji: /* i18n */ {
id: "blocks.chats.actions.addEmoji",
message: "Add emoji",
},
backToConversations: /* i18n */ {
id: "blocks.chats.actions.backToConversations",
message: "Back to conversations",
},
conversationDetails: /* i18n */ {
id: "blocks.chats.actions.conversationDetails",
message: "Conversation details",
},
loadEarlier: /* i18n */ {
id: "blocks.chats.actions.loadEarlier",
message: "Load earlier messages",
},
removeAttachment: /* i18n */ {
id: "blocks.chats.actions.removeAttachment",
message: "Remove {name}",
},
retry: /* i18n */ {
id: "blocks.chats.actions.retry",
message: "Try again",
},
send: /* i18n */ {
id: "blocks.chats.actions.send",
message: "Send",
},
sendOnEnter: /* i18n */ {
id: "blocks.chats.actions.sendOnEnter",
message: "Press Enter to send",
},
sending: /* i18n */ {
id: "blocks.chats.actions.sending",
message: "Sending…",
},
delivered: /* i18n */ {
id: "blocks.chats.messageStatus.delivered",
message: "Delivered",
},
failed: /* i18n */ {
id: "blocks.chats.messageStatus.failed",
message: "Not sent",
},
read: /* i18n */ {
id: "blocks.chats.messageStatus.read",
message: "Read",
},
recentEmojis: /* i18n */ {
id: "blocks.chats.emojiPicker.recentEmojis",
message: "Recently used",
},
sent: /* i18n */ {
id: "blocks.chats.messageStatus.sent",
message: "Sent",
},
sendingStatus: /* i18n */ {
id: "blocks.chats.messageStatus.sending",
message: "Sending",
},
emptyConversationsDescription: /* i18n */ {
id: "blocks.chats.emptyConversations.description",
message: "Start a conversation to see it here.",
},
emptyConversationsTitle: /* i18n */ {
id: "blocks.chats.emptyConversations.title",
message: "No conversations",
},
emptyMessagesDescription: /* i18n */ {
id: "blocks.chats.emptyMessages.description",
message: "Messages in this conversation will appear here.",
},
emptyMessagesTitle: /* i18n */ {
id: "blocks.chats.emptyMessages.title",
message: "No messages yet",
},
noSearchResults: /* i18n */ {
id: "blocks.chats.emptySearch",
message: "No conversations match your search.",
},
messagePlaceholder: /* i18n */ {
id: "blocks.chats.messagePlaceholder",
message: "Write a message…",
},
searchConversations: /* i18n */ {
id: "blocks.chats.search",
message: "Search conversations",
},
selectConversation: /* i18n */ {
id: "blocks.chats.selectConversation",
message: "Select conversation {title}",
},
selectConversationDescription: /* i18n */ {
id: "blocks.chats.selectConversation.description",
message: "Choose a conversation from the list to read its messages.",
},
title: /* i18n */ {
id: "blocks.chats.title",
message: "Chats",
+126
View File
@@ -1,5 +1,131 @@
export interface ChatThread {
avatarUrl: string
id?: string
name: string
presence?: "offline" | "online"
}
export interface ChatParticipant {
avatarUrl?: string
id: string
name: string
}
export type ChatMessageStatus =
| "delivered"
| "failed"
| "read"
| "sending"
| "sent"
/**
* Content accepted by the WeChat Mini Program customer-service message API.
*
* This deliberately excludes subscription messages. Subscription messages are
* one-way, template-based notifications and use a different payload shape.
*/
export type WeChatMiniProgramCustomerServiceMessage =
| WeChatMiniProgramCustomerServiceTextMessage
| WeChatMiniProgramCustomerServiceImageMessage
| WeChatMiniProgramCustomerServiceLinkMessage
| WeChatMiniProgramCustomerServicePageMessage
export interface WeChatMiniProgramCustomerServiceTextMessage {
msgtype: "text"
text: {
content: string
}
}
export interface WeChatMiniProgramCustomerServiceImageMessage {
image: {
media_id: string
}
msgtype: "image"
}
export interface WeChatMiniProgramCustomerServiceLinkMessage {
link: {
description: string
thumb_url: string
title: string
url: string
}
msgtype: "link"
}
export interface WeChatMiniProgramCustomerServicePageMessage {
miniprogrampage: {
pagepath: string
thumb_media_id: string
title: string
}
msgtype: "miniprogrampage"
}
/** A request body ready for the WeChat Mini Program customer-message API. */
export type WeChatMiniProgramCustomerServiceMessageRequest = {
touser: string
} & WeChatMiniProgramCustomerServiceMessage
interface ChatMessageMetadata {
author?: ChatParticipant
/** An ISO timestamp used for the default time label. */
createdAt?: string
direction: "incoming" | "outgoing"
id: string
status?: ChatMessageStatus
/** Use host-formatted labels when the conversation needs a custom calendar. */
timeLabel?: string
}
export type ChatTextMessage = ChatMessageMetadata &
WeChatMiniProgramCustomerServiceTextMessage
export type ChatImageMessage = ChatMessageMetadata &
WeChatMiniProgramCustomerServiceImageMessage
export type ChatLinkMessage = ChatMessageMetadata &
WeChatMiniProgramCustomerServiceLinkMessage
export type ChatMiniProgramPageMessage = ChatMessageMetadata &
WeChatMiniProgramCustomerServicePageMessage
export type ChatMessage =
| ChatTextMessage
| ChatImageMessage
| ChatLinkMessage
| ChatMiniProgramPageMessage
export type ChatMediaUrlResolver = (
mediaId: string,
message: ChatImageMessage | ChatMiniProgramPageMessage
) => string | undefined
export interface ChatConversation {
/** The customer's profile image for this one-to-one service conversation. */
avatarUrl?: string
description?: string
/** A stable customer or customer-service session identifier. */
id: string
/** Host-formatted last-activity text, such as "16:58" or "Yesterday". */
lastActivityLabel?: string
lastMessage?: string
messages: readonly ChatMessage[]
/** The customer's display name. */
title: string
unreadCount?: number
}
export interface ChatSendEvent {
conversation: ChatConversation
/**
* Raw files selected in the composer. The host uploads them and maps each
* one to a supported WeChat message type (for example, an image `media_id`).
*/
files: readonly File[]
/**
* Unicode plain text read from Lexical. This is never HTML or Lexical JSON
* and maps directly to a WeChat `text.content` payload.
*/
text: string
}
export type ChatSendHandler = (event: ChatSendEvent) => void | Promise<void>
+25
View File
@@ -0,0 +1,25 @@
import type { ChatConversation } from "./types"
export function filterChatConversations(
conversations: readonly ChatConversation[],
query: string
): readonly ChatConversation[] {
const normalizedQuery = query.trim().toLocaleLowerCase()
if (!normalizedQuery) return conversations
return conversations.filter((conversation) =>
[conversation.title, conversation.description, conversation.lastMessage]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLocaleLowerCase().includes(normalizedQuery))
)
}
export function getInitialConversationId(
conversations: readonly ChatConversation[],
preferredId?: string
) {
if (preferredId && conversations.some(({ id }) => id === preferredId)) {
return preferredId
}
return conversations[0]?.id
}
+4 -3
View File
@@ -2,12 +2,13 @@ import { describe, expectTypeOf, it } from "vitest"
import type { ChatMessageCatalog } from "../blocks/chats/locales/catalogs"
import type { NavigationMessageCatalog } from "../blocks/navigation/locales/catalogs"
import { chatMessages } from "../blocks/chats/messages"
describe("block message catalog types", () => {
it("preserves semantic message IDs as literal keys", () => {
expectTypeOf<
keyof ChatMessageCatalog
>().toEqualTypeOf<"blocks.chats.title">()
expectTypeOf<keyof ChatMessageCatalog>().toEqualTypeOf<
(typeof chatMessages)[keyof typeof chatMessages]["id"]
>()
expectTypeOf<keyof NavigationMessageCatalog>().toEqualTypeOf<
| "blocks.navigation.description"
| "blocks.navigation.open"