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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user