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:
@@ -57,6 +57,7 @@
|
|||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
"@base-ui/utils": "^0.3.1",
|
"@base-ui/utils": "^0.3.1",
|
||||||
"@floating-ui/utils": "^0.2.12",
|
"@floating-ui/utils": "^0.2.12",
|
||||||
|
"@lexical/react": "^0.48.0",
|
||||||
"@tanstack/react-hotkeys": "^0.10.0",
|
"@tanstack/react-hotkeys": "^0.10.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-router": "^1.170.18",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"@workspace/icons": "workspace:*",
|
"@workspace/icons": "workspace:*",
|
||||||
"@workspace/ui": "workspace:*",
|
"@workspace/ui": "workspace:*",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"lexical": "^0.48.0",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
"@base-ui/utils": "^0.3.1",
|
"@base-ui/utils": "^0.3.1",
|
||||||
"@floating-ui/utils": "^0.2.12",
|
"@floating-ui/utils": "^0.2.12",
|
||||||
|
"@lexical/react": "^0.48.0",
|
||||||
"@tanstack/react-hotkeys": "^0.10.0",
|
"@tanstack/react-hotkeys": "^0.10.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-router": "^1.170.18",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@workspace/i18n": "workspace:*",
|
"@workspace/i18n": "workspace:*",
|
||||||
"@workspace/ui": "workspace:*",
|
"@workspace/ui": "workspace:*",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"lexical": "^0.48.0",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { ChatPopover } from "./chat-popover"
|
||||||
export type { ChatPopoverProps } from "./chat-popover"
|
export type { ChatPopoverProps } from "./chat-popover"
|
||||||
export { ChatPopoverTrigger } from "./chat-popover-trigger"
|
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 locale = "de"
|
||||||
export const languageTag = "de-DE"
|
export const languageTag = "de-DE"
|
||||||
export const messages = {
|
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",
|
"blocks.chats.title": "Chats",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "en"
|
export const locale = "en"
|
||||||
export const languageTag = "en-US"
|
export const languageTag = "en-US"
|
||||||
export const messages = {
|
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",
|
"blocks.chats.title": "Chats",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,36 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "es"
|
export const locale = "es"
|
||||||
export const languageTag = "es-ES"
|
export const languageTag = "es-ES"
|
||||||
export const messages = {
|
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",
|
"blocks.chats.title": "Chats",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,36 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "fr"
|
export const locale = "fr"
|
||||||
export const languageTag = "fr-FR"
|
export const languageTag = "fr-FR"
|
||||||
export const messages = {
|
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",
|
"blocks.chats.title": "Discussions",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "ja"
|
export const locale = "ja"
|
||||||
export const languageTag = "ja-JP"
|
export const languageTag = "ja-JP"
|
||||||
export const messages = {
|
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": "チャット",
|
"blocks.chats.title": "チャット",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "ko"
|
export const locale = "ko"
|
||||||
export const languageTag = "ko-KR"
|
export const languageTag = "ko-KR"
|
||||||
export const messages = {
|
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": "채팅",
|
"blocks.chats.title": "채팅",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,32 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "zh-Hans"
|
export const locale = "zh-Hans"
|
||||||
export const languageTag = "zh-CN"
|
export const languageTag = "zh-CN"
|
||||||
export const messages = {
|
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": "聊天",
|
"blocks.chats.title": "聊天",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -3,5 +3,32 @@ import type { ChatMessageCatalog } from "./catalogs"
|
|||||||
export const locale = "zh-Hant"
|
export const locale = "zh-Hant"
|
||||||
export const languageTag = "zh-TW"
|
export const languageTag = "zh-TW"
|
||||||
export const messages = {
|
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": "聊天",
|
"blocks.chats.title": "聊天",
|
||||||
} as const satisfies ChatMessageCatalog
|
} as const satisfies ChatMessageCatalog
|
||||||
|
|||||||
@@ -1,6 +1,114 @@
|
|||||||
import type { MessageDescriptor } from "@workspace/i18n"
|
import type { MessageDescriptor } from "@workspace/i18n"
|
||||||
|
|
||||||
export const chatMessages = {
|
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 */ {
|
title: /* i18n */ {
|
||||||
id: "blocks.chats.title",
|
id: "blocks.chats.title",
|
||||||
message: "Chats",
|
message: "Chats",
|
||||||
|
|||||||
@@ -1,5 +1,131 @@
|
|||||||
export interface ChatThread {
|
export interface ChatThread {
|
||||||
avatarUrl: string
|
avatarUrl: string
|
||||||
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
presence?: "offline" | "online"
|
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 { ChatMessageCatalog } from "../blocks/chats/locales/catalogs"
|
||||||
import type { NavigationMessageCatalog } from "../blocks/navigation/locales/catalogs"
|
import type { NavigationMessageCatalog } from "../blocks/navigation/locales/catalogs"
|
||||||
|
import { chatMessages } from "../blocks/chats/messages"
|
||||||
|
|
||||||
describe("block message catalog types", () => {
|
describe("block message catalog types", () => {
|
||||||
it("preserves semantic message IDs as literal keys", () => {
|
it("preserves semantic message IDs as literal keys", () => {
|
||||||
expectTypeOf<
|
expectTypeOf<keyof ChatMessageCatalog>().toEqualTypeOf<
|
||||||
keyof ChatMessageCatalog
|
(typeof chatMessages)[keyof typeof chatMessages]["id"]
|
||||||
>().toEqualTypeOf<"blocks.chats.title">()
|
>()
|
||||||
expectTypeOf<keyof NavigationMessageCatalog>().toEqualTypeOf<
|
expectTypeOf<keyof NavigationMessageCatalog>().toEqualTypeOf<
|
||||||
| "blocks.navigation.description"
|
| "blocks.navigation.description"
|
||||||
| "blocks.navigation.open"
|
| "blocks.navigation.open"
|
||||||
|
|||||||
Reference in New Issue
Block a user