From 87df34a8abbf0b14d302aa7a74596316ed206741 Mon Sep 17 00:00:00 2001 From: Maofeng Date: Sat, 8 Aug 2026 21:14:26 +0800 Subject: [PATCH] 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. --- bun.lock | 2 + packages/blocks/package.json | 2 + .../blocks/src/blocks/chats/chat-composer.tsx | 526 ++++++++++++++++++ .../blocks/chats/chat-conversation-list.tsx | 134 +++++ .../blocks/chats/chat-message-list.test.tsx | 87 +++ .../src/blocks/chats/chat-message-list.tsx | 348 ++++++++++++ .../src/blocks/chats/chat-workspace.test.tsx | 220 ++++++++ .../src/blocks/chats/chat-workspace.tsx | 304 ++++++++++ packages/blocks/src/blocks/chats/emojis.ts | 297 ++++++++++ packages/blocks/src/blocks/chats/index.ts | 23 +- .../blocks/src/blocks/chats/locales/de.ts | 30 + .../blocks/src/blocks/chats/locales/en.ts | 30 + .../blocks/src/blocks/chats/locales/es.ts | 31 ++ .../blocks/src/blocks/chats/locales/fr.ts | 31 ++ .../blocks/src/blocks/chats/locales/ja.ts | 30 + .../blocks/src/blocks/chats/locales/ko.ts | 30 + .../src/blocks/chats/locales/zh-Hans.ts | 27 + .../src/blocks/chats/locales/zh-Hant.ts | 27 + packages/blocks/src/blocks/chats/messages.ts | 108 ++++ packages/blocks/src/blocks/chats/types.ts | 126 +++++ packages/blocks/src/blocks/chats/utils.ts | 25 + packages/blocks/src/i18n/catalogs.test.ts | 7 +- 22 files changed, 2441 insertions(+), 4 deletions(-) create mode 100644 packages/blocks/src/blocks/chats/chat-composer.tsx create mode 100644 packages/blocks/src/blocks/chats/chat-conversation-list.tsx create mode 100644 packages/blocks/src/blocks/chats/chat-message-list.test.tsx create mode 100644 packages/blocks/src/blocks/chats/chat-message-list.tsx create mode 100644 packages/blocks/src/blocks/chats/chat-workspace.test.tsx create mode 100644 packages/blocks/src/blocks/chats/chat-workspace.tsx create mode 100644 packages/blocks/src/blocks/chats/emojis.ts create mode 100644 packages/blocks/src/blocks/chats/utils.ts diff --git a/bun.lock b/bun.lock index 570ac83..0fef031 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "@base-ui/react": "^1.6.0", "@base-ui/utils": "^0.3.1", "@floating-ui/utils": "^0.2.12", + "@lexical/react": "^0.48.0", "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -64,6 +65,7 @@ "@workspace/icons": "workspace:*", "@workspace/ui": "workspace:*", "cmdk": "^1.1.1", + "lexical": "^0.48.0", "lucide-react": "^1.27.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 9ed0226..fffdbab 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -11,6 +11,7 @@ "@base-ui/react": "^1.6.0", "@base-ui/utils": "^0.3.1", "@floating-ui/utils": "^0.2.12", + "@lexical/react": "^0.48.0", "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -18,6 +19,7 @@ "@workspace/i18n": "workspace:*", "@workspace/ui": "workspace:*", "cmdk": "^1.1.1", + "lexical": "^0.48.0", "lucide-react": "^1.27.0", "react": "^19.2.6", "react-dom": "^19.2.6" diff --git a/packages/blocks/src/blocks/chats/chat-composer.tsx b/packages/blocks/src/blocks/chats/chat-composer.tsx new file mode 100644 index 0000000..a36ad7c --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-composer.tsx @@ -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(null) + const imageInputRef = React.useRef(null) + const editorRef = React.useRef(null) + const initialDraftRef = React.useRef(draft) + const [files, setFiles] = React.useState([]) + 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 ( + + +
{ + event.preventDefault() + void send() + }} + > + {files.length > 0 && ( +
+ {files.map((file, index) => ( + + {file.name} + + + ))} +
+ )} + { + onDraftChange(nextDraft) + setSendFailed(false) + }} + onSend={() => void send()} + sendOnEnter={sendOnEnter} + placeholder={t(chatMessages.messagePlaceholder)} + /> +
+ { + appendFiles(event.target.files) + event.currentTarget.value = "" + }} + /> + { + appendFiles(event.target.files) + event.currentTarget.value = "" + }} + /> + + + + {sendFailed && ( + + {t(chatMessages.failed)} + + )} +
+ + +
+
+ +
+ ) +} + +function ChatEditorRefPlugin({ + editorRef, +}: { + editorRef: React.RefObject +}) { + 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) => { + 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 ( +
+ + {placeholder} +
+ } + className="min-h-22 px-3 py-2 text-sm outline-none" + /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + ) +} + +function ChatEmojiPicker({ disabled }: { disabled: boolean }) { + const t = useTranslate() + const [editor] = useLexicalComposerContext() + const [open, setOpen] = React.useState(false) + const [recentEmojis, setRecentEmojis] = + React.useState(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 ( + + + + + } + /> + +
+ + +
+
+
+ {customerServiceEmojiGroups.map((group) => ( + + ))} +
+
+
+
+ ) +} + +function EmojiSection({ + className, + emojis, + onSelect, + title, +}: { + className?: string + emojis: readonly string[] + onSelect: (emoji: string) => void + title: string +}) { + return ( +
+

{title}

+
+ {emojis.map((emoji) => ( + + ))} +
+
+ ) +} + +const maximumRecentEmojiCount = 10 +const recentEmojiStorageKey = "workspace.chats.recent-emojis" +const supportedEmojis = new Set(customerServiceEmojis) +const defaultRecentEmojis = customerServiceEmojis.slice( + 0, + maximumRecentEmojiCount +) + +function ChatDraftPlugin({ + draft, + onDraftChange, +}: { + draft: string + onDraftChange: (draft: string) => void +}) { + const [editor] = useLexicalComposerContext() + const lastDraftRef = React.useRef(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 ( + { + 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 +} diff --git a/packages/blocks/src/blocks/chats/chat-conversation-list.tsx b/packages/blocks/src/blocks/chats/chat-conversation-list.tsx new file mode 100644 index 0000000..c7d9a82 --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-conversation-list.tsx @@ -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 ( + + ) +} + +export function ConversationAvatar({ + conversation, + size = "default", +}: { + conversation: Pick + size?: "default" | "lg" | "sm" +}) { + return ( + + {conversation.avatarUrl && } + {conversation.title.slice(0, 1)} + + ) +} diff --git a/packages/blocks/src/blocks/chats/chat-message-list.test.tsx b/packages/blocks/src/blocks/chats/chat-message-list.test.tsx new file mode 100644 index 0000000..baeb752 --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-message-list.test.tsx @@ -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( + + + + ) + + 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] + ) + }) +}) diff --git a/packages/blocks/src/blocks/chats/chat-message-list.tsx b/packages/blocks/src/blocks/chats/chat-message-list.tsx new file mode 100644 index 0000000..5769283 --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-message-list.tsx @@ -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 ( + + + + + + {t(chatMessages.emptyMessagesTitle)} + + {t(chatMessages.emptyMessagesDescription)} + + + + ) + } + + return ( + + + + + {hasMoreMessages && onLoadEarlierMessages && ( + + + + )} + {conversation.messages.map((message) => ( + + + + ))} + + + + + + ) +} + +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 ( + + {!outgoing && ( + + + {message.author?.avatarUrl && ( + + )} + {authorName?.slice(0, 1) ?? "?"} + + + )} + + {!outgoing && authorName && {authorName}} + + + + + + {(time || status) && ( + + {[time, status].filter(Boolean).join(" · ")} + + )} + + + ) +} + +function MessagePayload({ + message, + resolveMediaUrl, +}: { + message: ChatMessage + resolveMediaUrl?: ChatMediaUrlResolver +}) { + switch (message.msgtype) { + case "text": + return ( +

+ {message.text.content} +

+ ) + case "image": + return ( + + ) + case "link": + return + case "miniprogrampage": + return ( + + ) + } +} + +function ImageMessage({ + message, + resolveMediaUrl, +}: { + message: ChatImageMessage + resolveMediaUrl?: ChatMediaUrlResolver +}) { + const url = resolveMediaUrl?.(message.image.media_id, message) + + if (url) { + return ( + + {message.image.media_id} + + ) + } + + return +} + +function LinkMessage({ + message, +}: { + message: Extract +}) { + const { link } = message + + return ( + + {link.thumb_url && ( + + )} + + + + + {link.title} + + + {link.description} + + + + + ) +} + +function MiniProgramPageMessage({ + message, + resolveMediaUrl, +}: { + message: ChatMiniProgramPageMessage + resolveMediaUrl?: ChatMediaUrlResolver +}) { + const { miniprogrampage } = message + const thumbnailUrl = resolveMediaUrl?.( + miniprogrampage.thumb_media_id, + message + ) + + return ( +
+ {thumbnailUrl ? ( + + ) : ( + + )} + + + {miniprogrampage.title} + + + {miniprogrampage.pagepath} + + +
+ ) +} + +function MediaPlaceholder({ + className, + icon: Icon, + mediaId, +}: { + className?: string + icon: typeof ImageIcon + mediaId: string +}) { + return ( + + + {mediaId} + + ) +} + +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" }) +} diff --git a/packages/blocks/src/blocks/chats/chat-workspace.test.tsx b/packages/blocks/src/blocks/chats/chat-workspace.test.tsx new file mode 100644 index 0000000..7bb6c0d --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-workspace.test.tsx @@ -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( + + + + ) +} + +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('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( + + + + ) + + await waitFor(() => expect(editor.textContent).toBe("Lexical draft")) + }) +}) diff --git a/packages/blocks/src/blocks/chats/chat-workspace.tsx b/packages/blocks/src/blocks/chats/chat-workspace.tsx new file mode 100644 index 0000000..29e86eb --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-workspace.tsx @@ -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 ( +
+ + {showConversation && ( +
+ {activeConversation ? ( + <> + setMobileConversationOpen(false) : undefined + } + onInfo={ + onConversationInfo + ? () => onConversationInfo(activeConversation) + : undefined + } + /> + + + + ) : conversations.length === 0 ? ( + + ) : ( + + )} +
+ )} + {!showConversation && isMobile && ( +
{t(chatMessages.backToConversations)}
+ )} +
+ ) +} + +function ChatWorkspaceHeader({ + conversation, + onBack, + onInfo, +}: { + conversation: ChatConversation + onBack?: VoidFunction + onInfo?: VoidFunction +}) { + const t = useTranslate() + + return ( +
+ {onBack && ( + + )} + +
+

+ {conversation.title} +

+ {conversation.description && ( +

+ {conversation.description} +

+ )} +
+ {onInfo && ( + + )} +
+ ) +} + +function EmptyChatState({ + description, + title, +}: { + description: React.ReactNode + title: React.ReactNode +}) { + return ( + + + + + + {title} + {description} + + + ) +} diff --git a/packages/blocks/src/blocks/chats/emojis.ts b/packages/blocks/src/blocks/chats/emojis.ts new file mode 100644 index 0000000..25829a2 --- /dev/null +++ b/packages/blocks/src/blocks/chats/emojis.ts @@ -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 diff --git a/packages/blocks/src/blocks/chats/index.ts b/packages/blocks/src/blocks/chats/index.ts index 8f4a53a..bdccccf 100644 --- a/packages/blocks/src/blocks/chats/index.ts +++ b/packages/blocks/src/blocks/chats/index.ts @@ -1,4 +1,25 @@ export { ChatPopover } from "./chat-popover" export type { ChatPopoverProps } from "./chat-popover" export { ChatPopoverTrigger } from "./chat-popover-trigger" -export type { ChatThread } from "./types" +export { ChatWorkspace, type ChatWorkspaceProps } from "./chat-workspace" +export type { + ChatConversation, + ChatImageMessage, + ChatLinkMessage, + ChatMessage, + ChatMediaUrlResolver, + ChatMessageStatus, + ChatMiniProgramPageMessage, + ChatParticipant, + ChatSendEvent, + ChatSendHandler, + ChatTextMessage, + ChatThread, + WeChatMiniProgramCustomerServiceImageMessage, + WeChatMiniProgramCustomerServiceLinkMessage, + WeChatMiniProgramCustomerServiceMessage, + WeChatMiniProgramCustomerServiceMessageRequest, + WeChatMiniProgramCustomerServicePageMessage, + WeChatMiniProgramCustomerServiceTextMessage, +} from "./types" +export { filterChatConversations, getInitialConversationId } from "./utils" diff --git a/packages/blocks/src/blocks/chats/locales/de.ts b/packages/blocks/src/blocks/chats/locales/de.ts index 31f8b6b..941c077 100644 --- a/packages/blocks/src/blocks/chats/locales/de.ts +++ b/packages/blocks/src/blocks/chats/locales/de.ts @@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "de" export const languageTag = "de-DE" export const messages = { + "blocks.chats.actions.addFiles": "Dateien hinzufügen", + "blocks.chats.actions.addEmoji": "Emoji hinzufügen", + "blocks.chats.actions.addImage": "Bild hinzufügen", + "blocks.chats.actions.backToConversations": "Zurück zu Unterhaltungen", + "blocks.chats.actions.conversationDetails": "Unterhaltungsdetails", + "blocks.chats.actions.loadEarlier": "Frühere Nachrichten laden", + "blocks.chats.actions.removeAttachment": "{name} entfernen", + "blocks.chats.actions.retry": "Erneut versuchen", + "blocks.chats.actions.send": "Senden", + "blocks.chats.actions.sendOnEnter": "Mit Enter senden", + "blocks.chats.actions.sending": "Wird gesendet…", + "blocks.chats.emptyConversations.description": + "Starte eine Unterhaltung, damit sie hier erscheint.", + "blocks.chats.emptyConversations.title": "Keine Unterhaltungen", + "blocks.chats.emptyMessages.description": + "Nachrichten dieser Unterhaltung werden hier angezeigt.", + "blocks.chats.emptyMessages.title": "Noch keine Nachrichten", + "blocks.chats.emptySearch": "Keine Unterhaltungen entsprechen deiner Suche.", + "blocks.chats.emojiPicker.allEmojis": "Alle Emojis", + "blocks.chats.emojiPicker.recentEmojis": "Kürzlich verwendet", + "blocks.chats.messagePlaceholder": "Nachricht schreiben…", + "blocks.chats.messageStatus.delivered": "Zugestellt", + "blocks.chats.messageStatus.failed": "Nicht gesendet", + "blocks.chats.messageStatus.read": "Gelesen", + "blocks.chats.messageStatus.sending": "Wird gesendet", + "blocks.chats.messageStatus.sent": "Gesendet", + "blocks.chats.search": "Unterhaltungen suchen", + "blocks.chats.selectConversation": "Unterhaltung {title} auswählen", + "blocks.chats.selectConversation.description": + "Wähle eine Unterhaltung aus der Liste, um ihre Nachrichten zu lesen.", "blocks.chats.title": "Chats", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/en.ts b/packages/blocks/src/blocks/chats/locales/en.ts index 03142b0..ac4367b 100644 --- a/packages/blocks/src/blocks/chats/locales/en.ts +++ b/packages/blocks/src/blocks/chats/locales/en.ts @@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "en" export const languageTag = "en-US" export const messages = { + "blocks.chats.actions.addFiles": "Add files", + "blocks.chats.actions.addEmoji": "Add emoji", + "blocks.chats.actions.addImage": "Add image", + "blocks.chats.actions.backToConversations": "Back to conversations", + "blocks.chats.actions.conversationDetails": "Conversation details", + "blocks.chats.actions.loadEarlier": "Load earlier messages", + "blocks.chats.actions.removeAttachment": "Remove {name}", + "blocks.chats.actions.retry": "Try again", + "blocks.chats.actions.send": "Send", + "blocks.chats.actions.sendOnEnter": "Press Enter to send", + "blocks.chats.actions.sending": "Sending…", + "blocks.chats.emptyConversations.description": + "Start a conversation to see it here.", + "blocks.chats.emptyConversations.title": "No conversations", + "blocks.chats.emptyMessages.description": + "Messages in this conversation will appear here.", + "blocks.chats.emptyMessages.title": "No messages yet", + "blocks.chats.emptySearch": "No conversations match your search.", + "blocks.chats.emojiPicker.allEmojis": "All emojis", + "blocks.chats.emojiPicker.recentEmojis": "Recently used", + "blocks.chats.messagePlaceholder": "Write a message…", + "blocks.chats.messageStatus.delivered": "Delivered", + "blocks.chats.messageStatus.failed": "Not sent", + "blocks.chats.messageStatus.read": "Read", + "blocks.chats.messageStatus.sending": "Sending", + "blocks.chats.messageStatus.sent": "Sent", + "blocks.chats.search": "Search conversations", + "blocks.chats.selectConversation": "Select conversation {title}", + "blocks.chats.selectConversation.description": + "Choose a conversation from the list to read its messages.", "blocks.chats.title": "Chats", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/es.ts b/packages/blocks/src/blocks/chats/locales/es.ts index 3344b3d..4ed64b8 100644 --- a/packages/blocks/src/blocks/chats/locales/es.ts +++ b/packages/blocks/src/blocks/chats/locales/es.ts @@ -3,5 +3,36 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "es" export const languageTag = "es-ES" export const messages = { + "blocks.chats.actions.addFiles": "Añadir archivos", + "blocks.chats.actions.addEmoji": "Añadir emoji", + "blocks.chats.actions.addImage": "Añadir imagen", + "blocks.chats.actions.backToConversations": "Volver a conversaciones", + "blocks.chats.actions.conversationDetails": "Detalles de la conversación", + "blocks.chats.actions.loadEarlier": "Cargar mensajes anteriores", + "blocks.chats.actions.removeAttachment": "Quitar {name}", + "blocks.chats.actions.retry": "Intentar de nuevo", + "blocks.chats.actions.send": "Enviar", + "blocks.chats.actions.sendOnEnter": "Enviar con Intro", + "blocks.chats.actions.sending": "Enviando…", + "blocks.chats.emptyConversations.description": + "Inicia una conversación para verla aquí.", + "blocks.chats.emptyConversations.title": "No hay conversaciones", + "blocks.chats.emptyMessages.description": + "Los mensajes de esta conversación aparecerán aquí.", + "blocks.chats.emptyMessages.title": "Aún no hay mensajes", + "blocks.chats.emptySearch": + "No hay conversaciones que coincidan con tu búsqueda.", + "blocks.chats.emojiPicker.allEmojis": "Todos los emojis", + "blocks.chats.emojiPicker.recentEmojis": "Usados recientemente", + "blocks.chats.messagePlaceholder": "Escribe un mensaje…", + "blocks.chats.messageStatus.delivered": "Entregado", + "blocks.chats.messageStatus.failed": "No enviado", + "blocks.chats.messageStatus.read": "Leído", + "blocks.chats.messageStatus.sending": "Enviando", + "blocks.chats.messageStatus.sent": "Enviado", + "blocks.chats.search": "Buscar conversaciones", + "blocks.chats.selectConversation": "Seleccionar conversación {title}", + "blocks.chats.selectConversation.description": + "Elige una conversación de la lista para leer sus mensajes.", "blocks.chats.title": "Chats", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/fr.ts b/packages/blocks/src/blocks/chats/locales/fr.ts index 517f63f..114506a 100644 --- a/packages/blocks/src/blocks/chats/locales/fr.ts +++ b/packages/blocks/src/blocks/chats/locales/fr.ts @@ -3,5 +3,36 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "fr" export const languageTag = "fr-FR" export const messages = { + "blocks.chats.actions.addFiles": "Ajouter des fichiers", + "blocks.chats.actions.addEmoji": "Ajouter un emoji", + "blocks.chats.actions.addImage": "Ajouter une image", + "blocks.chats.actions.backToConversations": "Retour aux discussions", + "blocks.chats.actions.conversationDetails": "Détails de la discussion", + "blocks.chats.actions.loadEarlier": "Charger les messages précédents", + "blocks.chats.actions.removeAttachment": "Retirer {name}", + "blocks.chats.actions.retry": "Réessayer", + "blocks.chats.actions.send": "Envoyer", + "blocks.chats.actions.sendOnEnter": "Envoyer avec Entrée", + "blocks.chats.actions.sending": "Envoi…", + "blocks.chats.emptyConversations.description": + "Démarrez une discussion pour la voir ici.", + "blocks.chats.emptyConversations.title": "Aucune discussion", + "blocks.chats.emptyMessages.description": + "Les messages de cette discussion apparaîtront ici.", + "blocks.chats.emptyMessages.title": "Pas encore de messages", + "blocks.chats.emptySearch": + "Aucune discussion ne correspond à votre recherche.", + "blocks.chats.emojiPicker.allEmojis": "Tous les emojis", + "blocks.chats.emojiPicker.recentEmojis": "Récemment utilisés", + "blocks.chats.messagePlaceholder": "Écrivez un message…", + "blocks.chats.messageStatus.delivered": "Distribué", + "blocks.chats.messageStatus.failed": "Non envoyé", + "blocks.chats.messageStatus.read": "Lu", + "blocks.chats.messageStatus.sending": "Envoi", + "blocks.chats.messageStatus.sent": "Envoyé", + "blocks.chats.search": "Rechercher des discussions", + "blocks.chats.selectConversation": "Sélectionner la discussion {title}", + "blocks.chats.selectConversation.description": + "Choisissez une discussion dans la liste pour lire ses messages.", "blocks.chats.title": "Discussions", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/ja.ts b/packages/blocks/src/blocks/chats/locales/ja.ts index ee8295e..eafb39f 100644 --- a/packages/blocks/src/blocks/chats/locales/ja.ts +++ b/packages/blocks/src/blocks/chats/locales/ja.ts @@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "ja" export const languageTag = "ja-JP" export const messages = { + "blocks.chats.actions.addFiles": "ファイルを追加", + "blocks.chats.actions.addEmoji": "絵文字を追加", + "blocks.chats.actions.addImage": "画像を追加", + "blocks.chats.actions.backToConversations": "会話一覧に戻る", + "blocks.chats.actions.conversationDetails": "会話の詳細", + "blocks.chats.actions.loadEarlier": "以前のメッセージを読み込む", + "blocks.chats.actions.removeAttachment": "{name} を削除", + "blocks.chats.actions.retry": "再試行", + "blocks.chats.actions.send": "送信", + "blocks.chats.actions.sendOnEnter": "Enter キーで送信", + "blocks.chats.actions.sending": "送信中…", + "blocks.chats.emptyConversations.description": + "会話を始めると、ここに表示されます。", + "blocks.chats.emptyConversations.title": "会話はありません", + "blocks.chats.emptyMessages.description": + "この会話のメッセージはここに表示されます。", + "blocks.chats.emptyMessages.title": "メッセージはまだありません", + "blocks.chats.emptySearch": "検索に一致する会話はありません。", + "blocks.chats.emojiPicker.allEmojis": "すべての絵文字", + "blocks.chats.emojiPicker.recentEmojis": "最近使用した絵文字", + "blocks.chats.messagePlaceholder": "メッセージを入力…", + "blocks.chats.messageStatus.delivered": "配信済み", + "blocks.chats.messageStatus.failed": "未送信", + "blocks.chats.messageStatus.read": "既読", + "blocks.chats.messageStatus.sending": "送信中", + "blocks.chats.messageStatus.sent": "送信済み", + "blocks.chats.search": "会話を検索", + "blocks.chats.selectConversation": "会話 {title} を選択", + "blocks.chats.selectConversation.description": + "一覧から会話を選択してメッセージを読みます。", "blocks.chats.title": "チャット", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/ko.ts b/packages/blocks/src/blocks/chats/locales/ko.ts index 6b03c4b..bb460aa 100644 --- a/packages/blocks/src/blocks/chats/locales/ko.ts +++ b/packages/blocks/src/blocks/chats/locales/ko.ts @@ -3,5 +3,35 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "ko" export const languageTag = "ko-KR" export const messages = { + "blocks.chats.actions.addFiles": "파일 추가", + "blocks.chats.actions.addEmoji": "이모지 추가", + "blocks.chats.actions.addImage": "이미지 추가", + "blocks.chats.actions.backToConversations": "대화 목록으로 돌아가기", + "blocks.chats.actions.conversationDetails": "대화 세부 정보", + "blocks.chats.actions.loadEarlier": "이전 메시지 불러오기", + "blocks.chats.actions.removeAttachment": "{name} 제거", + "blocks.chats.actions.retry": "다시 시도", + "blocks.chats.actions.send": "보내기", + "blocks.chats.actions.sendOnEnter": "Enter 키로 전송", + "blocks.chats.actions.sending": "보내는 중…", + "blocks.chats.emptyConversations.description": + "대화를 시작하면 여기에 표시됩니다.", + "blocks.chats.emptyConversations.title": "대화가 없습니다", + "blocks.chats.emptyMessages.description": + "이 대화의 메시지가 여기에 표시됩니다.", + "blocks.chats.emptyMessages.title": "아직 메시지가 없습니다", + "blocks.chats.emptySearch": "검색과 일치하는 대화가 없습니다.", + "blocks.chats.emojiPicker.allEmojis": "모든 이모지", + "blocks.chats.emojiPicker.recentEmojis": "최근 사용", + "blocks.chats.messagePlaceholder": "메시지 작성…", + "blocks.chats.messageStatus.delivered": "전달됨", + "blocks.chats.messageStatus.failed": "전송되지 않음", + "blocks.chats.messageStatus.read": "읽음", + "blocks.chats.messageStatus.sending": "보내는 중", + "blocks.chats.messageStatus.sent": "보냄", + "blocks.chats.search": "대화 검색", + "blocks.chats.selectConversation": "대화 {title} 선택", + "blocks.chats.selectConversation.description": + "목록에서 대화를 선택하여 메시지를 읽으세요.", "blocks.chats.title": "채팅", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/zh-Hans.ts b/packages/blocks/src/blocks/chats/locales/zh-Hans.ts index fc2d3db..0a24d56 100644 --- a/packages/blocks/src/blocks/chats/locales/zh-Hans.ts +++ b/packages/blocks/src/blocks/chats/locales/zh-Hans.ts @@ -3,5 +3,32 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "zh-Hans" export const languageTag = "zh-CN" export const messages = { + "blocks.chats.actions.addFiles": "添加文件", + "blocks.chats.actions.addEmoji": "添加表情", + "blocks.chats.actions.addImage": "添加图片", + "blocks.chats.actions.backToConversations": "返回会话列表", + "blocks.chats.actions.conversationDetails": "会话详情", + "blocks.chats.actions.loadEarlier": "加载更早的消息", + "blocks.chats.actions.removeAttachment": "移除 {name}", + "blocks.chats.actions.retry": "重试", + "blocks.chats.actions.send": "发送", + "blocks.chats.actions.sendOnEnter": "按 Enter 发送", + "blocks.chats.actions.sending": "正在发送…", + "blocks.chats.emptyConversations.description": "新建会话后会显示在这里。", + "blocks.chats.emptyConversations.title": "暂无会话", + "blocks.chats.emptyMessages.description": "此会话中的消息会显示在这里。", + "blocks.chats.emptyMessages.title": "暂无消息", + "blocks.chats.emptySearch": "没有匹配搜索条件的会话。", + "blocks.chats.emojiPicker.allEmojis": "所有表情", + "blocks.chats.emojiPicker.recentEmojis": "最近使用", + "blocks.chats.messagePlaceholder": "输入消息…", + "blocks.chats.messageStatus.delivered": "已送达", + "blocks.chats.messageStatus.failed": "发送失败", + "blocks.chats.messageStatus.read": "已读", + "blocks.chats.messageStatus.sending": "发送中", + "blocks.chats.messageStatus.sent": "已发送", + "blocks.chats.search": "搜索会话", + "blocks.chats.selectConversation": "选择会话 {title}", + "blocks.chats.selectConversation.description": "从列表中选择会话以查看消息。", "blocks.chats.title": "聊天", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/locales/zh-Hant.ts b/packages/blocks/src/blocks/chats/locales/zh-Hant.ts index 2529f3c..0555b8e 100644 --- a/packages/blocks/src/blocks/chats/locales/zh-Hant.ts +++ b/packages/blocks/src/blocks/chats/locales/zh-Hant.ts @@ -3,5 +3,32 @@ import type { ChatMessageCatalog } from "./catalogs" export const locale = "zh-Hant" export const languageTag = "zh-TW" export const messages = { + "blocks.chats.actions.addFiles": "新增檔案", + "blocks.chats.actions.addEmoji": "新增表情", + "blocks.chats.actions.addImage": "新增圖片", + "blocks.chats.actions.backToConversations": "返回對話列表", + "blocks.chats.actions.conversationDetails": "對話詳細資料", + "blocks.chats.actions.loadEarlier": "載入較早訊息", + "blocks.chats.actions.removeAttachment": "移除 {name}", + "blocks.chats.actions.retry": "再試一次", + "blocks.chats.actions.send": "傳送", + "blocks.chats.actions.sendOnEnter": "按 Enter 傳送", + "blocks.chats.actions.sending": "傳送中…", + "blocks.chats.emptyConversations.description": "開始對話後會顯示在這裡。", + "blocks.chats.emptyConversations.title": "沒有對話", + "blocks.chats.emptyMessages.description": "此對話的訊息會顯示在這裡。", + "blocks.chats.emptyMessages.title": "尚無訊息", + "blocks.chats.emptySearch": "沒有符合搜尋條件的對話。", + "blocks.chats.emojiPicker.allEmojis": "所有表情", + "blocks.chats.emojiPicker.recentEmojis": "最近使用", + "blocks.chats.messagePlaceholder": "輸入訊息…", + "blocks.chats.messageStatus.delivered": "已送達", + "blocks.chats.messageStatus.failed": "未傳送", + "blocks.chats.messageStatus.read": "已讀", + "blocks.chats.messageStatus.sending": "傳送中", + "blocks.chats.messageStatus.sent": "已傳送", + "blocks.chats.search": "搜尋對話", + "blocks.chats.selectConversation": "選取對話 {title}", + "blocks.chats.selectConversation.description": "從列表中選取對話以閱讀訊息。", "blocks.chats.title": "聊天", } as const satisfies ChatMessageCatalog diff --git a/packages/blocks/src/blocks/chats/messages.ts b/packages/blocks/src/blocks/chats/messages.ts index 3987c60..2ae71c5 100644 --- a/packages/blocks/src/blocks/chats/messages.ts +++ b/packages/blocks/src/blocks/chats/messages.ts @@ -1,6 +1,114 @@ import type { MessageDescriptor } from "@workspace/i18n" export const chatMessages = { + addFiles: /* i18n */ { + id: "blocks.chats.actions.addFiles", + message: "Add files", + }, + addImage: /* i18n */ { + id: "blocks.chats.actions.addImage", + message: "Add image", + }, + allEmojis: /* i18n */ { + id: "blocks.chats.emojiPicker.allEmojis", + message: "All emojis", + }, + addEmoji: /* i18n */ { + id: "blocks.chats.actions.addEmoji", + message: "Add emoji", + }, + backToConversations: /* i18n */ { + id: "blocks.chats.actions.backToConversations", + message: "Back to conversations", + }, + conversationDetails: /* i18n */ { + id: "blocks.chats.actions.conversationDetails", + message: "Conversation details", + }, + loadEarlier: /* i18n */ { + id: "blocks.chats.actions.loadEarlier", + message: "Load earlier messages", + }, + removeAttachment: /* i18n */ { + id: "blocks.chats.actions.removeAttachment", + message: "Remove {name}", + }, + retry: /* i18n */ { + id: "blocks.chats.actions.retry", + message: "Try again", + }, + send: /* i18n */ { + id: "blocks.chats.actions.send", + message: "Send", + }, + sendOnEnter: /* i18n */ { + id: "blocks.chats.actions.sendOnEnter", + message: "Press Enter to send", + }, + sending: /* i18n */ { + id: "blocks.chats.actions.sending", + message: "Sending…", + }, + delivered: /* i18n */ { + id: "blocks.chats.messageStatus.delivered", + message: "Delivered", + }, + failed: /* i18n */ { + id: "blocks.chats.messageStatus.failed", + message: "Not sent", + }, + read: /* i18n */ { + id: "blocks.chats.messageStatus.read", + message: "Read", + }, + recentEmojis: /* i18n */ { + id: "blocks.chats.emojiPicker.recentEmojis", + message: "Recently used", + }, + sent: /* i18n */ { + id: "blocks.chats.messageStatus.sent", + message: "Sent", + }, + sendingStatus: /* i18n */ { + id: "blocks.chats.messageStatus.sending", + message: "Sending", + }, + emptyConversationsDescription: /* i18n */ { + id: "blocks.chats.emptyConversations.description", + message: "Start a conversation to see it here.", + }, + emptyConversationsTitle: /* i18n */ { + id: "blocks.chats.emptyConversations.title", + message: "No conversations", + }, + emptyMessagesDescription: /* i18n */ { + id: "blocks.chats.emptyMessages.description", + message: "Messages in this conversation will appear here.", + }, + emptyMessagesTitle: /* i18n */ { + id: "blocks.chats.emptyMessages.title", + message: "No messages yet", + }, + noSearchResults: /* i18n */ { + id: "blocks.chats.emptySearch", + message: "No conversations match your search.", + }, + messagePlaceholder: /* i18n */ { + id: "blocks.chats.messagePlaceholder", + message: "Write a message…", + }, + searchConversations: /* i18n */ { + id: "blocks.chats.search", + message: "Search conversations", + }, + selectConversation: /* i18n */ { + id: "blocks.chats.selectConversation", + message: "Select conversation {title}", + }, + selectConversationDescription: /* i18n */ { + id: "blocks.chats.selectConversation.description", + message: "Choose a conversation from the list to read its messages.", + }, title: /* i18n */ { id: "blocks.chats.title", message: "Chats", diff --git a/packages/blocks/src/blocks/chats/types.ts b/packages/blocks/src/blocks/chats/types.ts index b1d53c0..2fdd78f 100644 --- a/packages/blocks/src/blocks/chats/types.ts +++ b/packages/blocks/src/blocks/chats/types.ts @@ -1,5 +1,131 @@ export interface ChatThread { avatarUrl: string + id?: string name: string presence?: "offline" | "online" } + +export interface ChatParticipant { + avatarUrl?: string + id: string + name: string +} + +export type ChatMessageStatus = + | "delivered" + | "failed" + | "read" + | "sending" + | "sent" + +/** + * Content accepted by the WeChat Mini Program customer-service message API. + * + * This deliberately excludes subscription messages. Subscription messages are + * one-way, template-based notifications and use a different payload shape. + */ +export type WeChatMiniProgramCustomerServiceMessage = + | WeChatMiniProgramCustomerServiceTextMessage + | WeChatMiniProgramCustomerServiceImageMessage + | WeChatMiniProgramCustomerServiceLinkMessage + | WeChatMiniProgramCustomerServicePageMessage + +export interface WeChatMiniProgramCustomerServiceTextMessage { + msgtype: "text" + text: { + content: string + } +} + +export interface WeChatMiniProgramCustomerServiceImageMessage { + image: { + media_id: string + } + msgtype: "image" +} + +export interface WeChatMiniProgramCustomerServiceLinkMessage { + link: { + description: string + thumb_url: string + title: string + url: string + } + msgtype: "link" +} + +export interface WeChatMiniProgramCustomerServicePageMessage { + miniprogrampage: { + pagepath: string + thumb_media_id: string + title: string + } + msgtype: "miniprogrampage" +} + +/** A request body ready for the WeChat Mini Program customer-message API. */ +export type WeChatMiniProgramCustomerServiceMessageRequest = { + touser: string +} & WeChatMiniProgramCustomerServiceMessage + +interface ChatMessageMetadata { + author?: ChatParticipant + /** An ISO timestamp used for the default time label. */ + createdAt?: string + direction: "incoming" | "outgoing" + id: string + status?: ChatMessageStatus + /** Use host-formatted labels when the conversation needs a custom calendar. */ + timeLabel?: string +} + +export type ChatTextMessage = ChatMessageMetadata & + WeChatMiniProgramCustomerServiceTextMessage +export type ChatImageMessage = ChatMessageMetadata & + WeChatMiniProgramCustomerServiceImageMessage +export type ChatLinkMessage = ChatMessageMetadata & + WeChatMiniProgramCustomerServiceLinkMessage +export type ChatMiniProgramPageMessage = ChatMessageMetadata & + WeChatMiniProgramCustomerServicePageMessage + +export type ChatMessage = + | ChatTextMessage + | ChatImageMessage + | ChatLinkMessage + | ChatMiniProgramPageMessage + +export type ChatMediaUrlResolver = ( + mediaId: string, + message: ChatImageMessage | ChatMiniProgramPageMessage +) => string | undefined + +export interface ChatConversation { + /** The customer's profile image for this one-to-one service conversation. */ + avatarUrl?: string + description?: string + /** A stable customer or customer-service session identifier. */ + id: string + /** Host-formatted last-activity text, such as "16:58" or "Yesterday". */ + lastActivityLabel?: string + lastMessage?: string + messages: readonly ChatMessage[] + /** The customer's display name. */ + title: string + unreadCount?: number +} + +export interface ChatSendEvent { + conversation: ChatConversation + /** + * Raw files selected in the composer. The host uploads them and maps each + * one to a supported WeChat message type (for example, an image `media_id`). + */ + files: readonly File[] + /** + * Unicode plain text read from Lexical. This is never HTML or Lexical JSON + * and maps directly to a WeChat `text.content` payload. + */ + text: string +} + +export type ChatSendHandler = (event: ChatSendEvent) => void | Promise diff --git a/packages/blocks/src/blocks/chats/utils.ts b/packages/blocks/src/blocks/chats/utils.ts new file mode 100644 index 0000000..0dd063f --- /dev/null +++ b/packages/blocks/src/blocks/chats/utils.ts @@ -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 +} diff --git a/packages/blocks/src/i18n/catalogs.test.ts b/packages/blocks/src/i18n/catalogs.test.ts index b0b49e4..eed631b 100644 --- a/packages/blocks/src/i18n/catalogs.test.ts +++ b/packages/blocks/src/i18n/catalogs.test.ts @@ -2,12 +2,13 @@ import { describe, expectTypeOf, it } from "vitest" import type { ChatMessageCatalog } from "../blocks/chats/locales/catalogs" import type { NavigationMessageCatalog } from "../blocks/navigation/locales/catalogs" +import { chatMessages } from "../blocks/chats/messages" describe("block message catalog types", () => { it("preserves semantic message IDs as literal keys", () => { - expectTypeOf< - keyof ChatMessageCatalog - >().toEqualTypeOf<"blocks.chats.title">() + expectTypeOf().toEqualTypeOf< + (typeof chatMessages)[keyof typeof chatMessages]["id"] + >() expectTypeOf().toEqualTypeOf< | "blocks.navigation.description" | "blocks.navigation.open"