From 3fd8d51bc4988d8c696cc809f282bdb5f8393206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Murat=20O=CC=88ner?= Date: Tue, 28 Jul 2026 20:41:08 +0300 Subject: [PATCH] feat: add bot avatar theming to ButtonsMessage and QuickReplyMessage components --- packages/ui/src/chat-ui/ButtonsMessage.ts | 86 ++++++++++++++----- packages/ui/src/chat-ui/QuickReplyMessage.ts | 53 ++++++++---- .../chat-ui/__tests__/ButtonsMessage.test.ts | 65 +++++++++++++- .../__tests__/QuickReplyMessage.test.ts | 86 +++++++++++++++++++ 4 files changed, 250 insertions(+), 40 deletions(-) create mode 100644 packages/ui/src/chat-ui/__tests__/QuickReplyMessage.test.ts diff --git a/packages/ui/src/chat-ui/ButtonsMessage.ts b/packages/ui/src/chat-ui/ButtonsMessage.ts index 0800ddf..cfc2bec 100644 --- a/packages/ui/src/chat-ui/ButtonsMessage.ts +++ b/packages/ui/src/chat-ui/ButtonsMessage.ts @@ -2,7 +2,7 @@ import { LitElement, html, css, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { marked } from "marked"; -import { MessageTypeRegistry, type MessageSender, type MessageAction } from "@chativa/core"; +import { MessageTypeRegistry, chatStore, type MessageSender, type MessageAction } from "@chativa/core"; /** Strip the wrapping

that marked adds for inline content. */ function renderInlineMarkdown(text: string): ReturnType { @@ -60,6 +60,13 @@ export class ButtonsMessage extends LitElement { .avatar.hidden { visibility: hidden; } .avatar svg { width: 16px; height: 16px; color: #7c3aed; } + .avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; + } .content { display: flex; @@ -104,6 +111,19 @@ export class ButtonsMessage extends LitElement { color: #ffffff; } + /* Markdown list labels (e.g. 1. Option) should not add browser default indent. */ + .action-btn ol, + .action-btn ul { + margin: 0; + padding: 0; + list-style-position: inside; + } + + .action-btn li { + margin: 0; + padding: 0; + } + .action-btn:active:not(:disabled) { opacity: 0.85; } @@ -121,11 +141,24 @@ export class ButtonsMessage extends LitElement { } .selected-label { + display: inline-flex; + align-items: center; + gap: 6px; font-size: 0.8125rem; color: #64748b; padding: 2px 4px; } + .selected-label ol, + .selected-label ul, + .selected-label li, + .selected-value { + display: inline; + margin: 0; + padding: 0; + list-style-position: inside; + } + .time { font-size: 0.6875rem; color: #94a3b8; @@ -180,38 +213,51 @@ export class ButtonsMessage extends LitElement { ); } + private _renderBotAvatar(avatarUrl?: string) { + return html` +
+ ${avatarUrl + ? html`bot avatar` + : html` + + + + + + + `} +
+ `; + } + render() { const isUser = this.sender === "user"; const text = this.messageData?.text ? String(this.messageData.text) : null; const buttons = (this.messageData?.buttons ?? []) as MessageAction[]; const persistent = this._persistent; + const avatarCfg = chatStore.getState().theme.avatar; + const showBotAvatar = avatarCfg?.showBot !== false; return html`
- ${!isUser - ? html` -
- - - - - - -
- ` - : nothing} + ${!isUser && showBotAvatar ? this._renderBotAvatar(avatarCfg?.bot) : nothing}
${text ? html`
${text}
` : nothing} ${!persistent && this._selected !== null /* One-time mode: replace buttons with confirmation label */ - ? html`✓ ${renderInlineMarkdown(this._selected!)}` + ? html` + + + ${renderInlineMarkdown(this._selected!)} + + ` /* Persistent mode or pre-selection: show full button list */ : html`
diff --git a/packages/ui/src/chat-ui/QuickReplyMessage.ts b/packages/ui/src/chat-ui/QuickReplyMessage.ts index 945a448..62f0def 100644 --- a/packages/ui/src/chat-ui/QuickReplyMessage.ts +++ b/packages/ui/src/chat-ui/QuickReplyMessage.ts @@ -1,6 +1,6 @@ import { LitElement, html, css, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { MessageTypeRegistry, type MessageSender, type MessageAction } from "@chativa/core"; +import { MessageTypeRegistry, chatStore, type MessageSender, type MessageAction } from "@chativa/core"; /** * Quick-reply message component. @@ -43,6 +43,13 @@ export class QuickReplyMessage extends LitElement { .avatar.hidden { visibility: hidden; } .avatar svg { width: 16px; height: 16px; color: #7c3aed; } + .avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; + } .content { display: flex; @@ -174,33 +181,41 @@ export class QuickReplyMessage extends LitElement { ); } + private _renderBotAvatar(avatarUrl?: string) { + return html` +
+ ${avatarUrl + ? html`bot avatar` + : html` + + + + + + + `} +
+ `; + } + render() { const isUser = this.sender === "user"; const actions = this.messageData?.actions as MessageAction[] | undefined; const keepActions = Boolean(this.messageData?.keepActions); const showChips = actions && actions.length > 0 && (!this._used || (keepActions && this._selectedValue !== null)); + const avatarCfg = chatStore.getState().theme.avatar; + const showBotAvatar = avatarCfg?.showBot !== false; return html`
- ${!isUser - ? html` -
- - - - - - -
- ` - : nothing} + ${!isUser && showBotAvatar ? this._renderBotAvatar(avatarCfg?.bot) : nothing}
${this.messageData?.text ? html`
${this.messageData.text as string}
` diff --git a/packages/ui/src/chat-ui/__tests__/ButtonsMessage.test.ts b/packages/ui/src/chat-ui/__tests__/ButtonsMessage.test.ts index add2200..e79aecc 100644 --- a/packages/ui/src/chat-ui/__tests__/ButtonsMessage.test.ts +++ b/packages/ui/src/chat-ui/__tests__/ButtonsMessage.test.ts @@ -1,9 +1,19 @@ import { describe, it, expect, vi } from "vitest"; import { marked } from "marked"; +const { getStateMock, storeState } = vi.hoisted(() => { + const storeState: { theme: { avatar?: { bot?: string; showBot?: boolean } } } = { + theme: {}, + }; + return { + getStateMock: vi.fn(() => storeState), + storeState, + }; +}); + vi.mock("@chativa/core", () => ({ MessageTypeRegistry: { register: vi.fn() }, - chatStore: { getState: () => ({ theme: {} }) }, + chatStore: { getState: getStateMock }, })); vi.mock("lit", () => ({ LitElement: class {}, @@ -20,6 +30,8 @@ vi.mock("lit/directives/unsafe-html.js", () => ({ unsafeHTML: (html: string) => html, })); +import { ButtonsMessage } from "../ButtonsMessage"; + /** Mirrors the renderInlineMarkdown helper in ButtonsMessage.ts */ function renderInlineMarkdown(text: string): string { return (marked.parse(text, { async: false }) as string).replace(/^

(.*)<\/p>\n?$/s, "$1"); @@ -43,3 +55,54 @@ describe("ButtonsMessage — label markdown rendering", () => { expect(result.startsWith("

")).toBe(false); }); }); + +function collectTemplateValues(node: unknown, out: string[] = []): string[] { + if (typeof node === "string") { + out.push(node); + return out; + } + if (!node || typeof node !== "object") return out; + if (Array.isArray(node)) { + node.forEach((n) => collectTemplateValues(n, out)); + return out; + } + + const maybeTemplate = node as { vals?: unknown[] }; + if (Array.isArray(maybeTemplate.vals)) { + collectTemplateValues(maybeTemplate.vals, out); + } + + Object.values(node).forEach((value) => collectTemplateValues(value, out)); + return out; +} + +describe("ButtonsMessage — avatar theming", () => { + it("uses configured bot avatar URL", () => { + storeState.theme.avatar = { bot: "https://cdn.example.com/bot.png" }; + + const message = new ButtonsMessage(); + message.sender = "bot"; + message.messageData = { text: "Hi", buttons: [{ label: "A" }] }; + + const rendered = message.render(); + const values = collectTemplateValues(rendered); + + expect(values).toContain("https://cdn.example.com/bot.png"); + }); + + it("hides bot avatar when showBot is false", () => { + storeState.theme.avatar = { + bot: "https://cdn.example.com/bot.png", + showBot: false, + }; + + const message = new ButtonsMessage(); + message.sender = "bot"; + message.messageData = { text: "Hi", buttons: [{ label: "A" }] }; + + const rendered = message.render(); + const values = collectTemplateValues(rendered); + + expect(values).not.toContain("https://cdn.example.com/bot.png"); + }); +}); diff --git a/packages/ui/src/chat-ui/__tests__/QuickReplyMessage.test.ts b/packages/ui/src/chat-ui/__tests__/QuickReplyMessage.test.ts new file mode 100644 index 0000000..5c621dd --- /dev/null +++ b/packages/ui/src/chat-ui/__tests__/QuickReplyMessage.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest"; + +const { getStateMock, storeState } = vi.hoisted(() => { + const storeState: { theme: { avatar?: { bot?: string; showBot?: boolean } } } = { + theme: {}, + }; + return { + getStateMock: vi.fn(() => storeState), + storeState, + }; +}); + +vi.mock("@chativa/core", () => ({ + MessageTypeRegistry: { register: vi.fn() }, + chatStore: { getState: getStateMock }, +})); +vi.mock("lit", () => ({ + LitElement: class {}, + html: (strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals }), + css: (strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals }), + nothing: null, +})); +vi.mock("lit/decorators.js", () => ({ + customElement: () => () => {}, + property: () => () => {}, + state: () => () => {}, +})); + +import { QuickReplyMessage } from "../QuickReplyMessage"; + +function collectTemplateValues(node: unknown, out: string[] = []): string[] { + if (typeof node === "string") { + out.push(node); + return out; + } + if (!node || typeof node !== "object") return out; + if (Array.isArray(node)) { + node.forEach((n) => collectTemplateValues(n, out)); + return out; + } + + const maybeTemplate = node as { vals?: unknown[] }; + if (Array.isArray(maybeTemplate.vals)) { + collectTemplateValues(maybeTemplate.vals, out); + } + + Object.values(node).forEach((value) => collectTemplateValues(value, out)); + return out; +} + +describe("QuickReplyMessage — avatar theming", () => { + it("uses configured bot avatar URL", () => { + storeState.theme.avatar = { bot: "https://cdn.example.com/bot-quick.png" }; + + const message = new QuickReplyMessage(); + message.sender = "bot"; + message.messageData = { + text: "Choose", + actions: [{ label: "Yes", value: "yes" }], + }; + + const rendered = message.render(); + const values = collectTemplateValues(rendered); + + expect(values).toContain("https://cdn.example.com/bot-quick.png"); + }); + + it("hides bot avatar when showBot is false", () => { + storeState.theme.avatar = { + bot: "https://cdn.example.com/bot-quick.png", + showBot: false, + }; + + const message = new QuickReplyMessage(); + message.sender = "bot"; + message.messageData = { + text: "Choose", + actions: [{ label: "Yes", value: "yes" }], + }; + + const rendered = message.render(); + const values = collectTemplateValues(rendered); + + expect(values).not.toContain("https://cdn.example.com/bot-quick.png"); + }); +});