From abb6e4235f669bbc70ccb37032d2fe2b9d8d5701 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 17:41:18 +0800 Subject: [PATCH] Add chat gifts and relationship diary UI --- next.config.ts | 4 +- .../__tests__/chat-input-bar.test.tsx | 174 +++++++++ .../__tests__/tailwind-components.test.tsx | 31 +- src/app/chat/components/chat-area.tsx | 1 + .../chat-composer-action-menu.module.css | 148 ++++++++ .../components/chat-composer-action-menu.tsx | 72 ++++ .../chat/components/chat-input-bar.module.css | 47 ++- src/app/chat/components/chat-input-bar.tsx | 119 +++++- .../chat/components/chat-input-text-field.tsx | 2 +- src/app/chat/components/chat-send-button.tsx | 48 ++- .../commercial-action-card.module.css | 63 ++++ .../components/commercial-action-card.tsx | 58 +++ src/app/chat/components/message-bubble.tsx | 5 + src/app/chat/components/message-content.tsx | 11 + src/app/private-room/components/index.ts | 1 + .../relationship-diary-panel.module.css | 239 ++++++++++++ .../components/relationship-diary-panel.tsx | 354 ++++++++++++++++++ .../private-room-screen.module.css | 55 +++ src/app/private-room/private-room-screen.tsx | 48 ++- .../__tests__/tip-screen.checkout.test.tsx | 1 + src/app/tip/tip-screen.tsx | 3 +- .../chat/__tests__/commercial_action.test.ts | 36 ++ src/data/schemas/chat/commercial_action.ts | 14 + src/data/schemas/chat/index.ts | 3 + .../chat/request/tip_offer_action_request.ts | 15 + .../chat/response/chat_send_response.ts | 3 + .../response/tip_offer_action_response.ts | 25 ++ src/data/schemas/payment/gift_offer.ts | 20 + src/data/schemas/payment/index.ts | 1 + .../response/gift_products_response.ts | 8 +- .../payment/response/tip_message_response.ts | 4 + .../__tests__/relationship_diary.test.ts | 64 ++++ src/data/schemas/private-room/index.ts | 5 + .../private-room/relationship_diary.ts | 27 ++ .../unlock_relationship_diary_request.ts | 11 + .../response/relationship_diaries_response.ts | 24 ++ .../relationship_diary_seen_response.ts | 15 + .../relationship_diary_unlock_response.ts | 35 ++ .../services/api/__tests__/api_path.test.ts | 16 +- src/data/services/api/api_contract.json | 4 + src/data/services/api/api_path.ts | 22 ++ src/data/services/api/chat_api.ts | 17 + src/data/services/api/private_room_api.ts | 53 +++ .../__tests__/navigation_storage.test.ts | 27 +- .../storage/navigation/navigation_storage.ts | 22 +- src/lib/commercial/tip_offer_session.ts | 39 ++ src/lib/navigation/chat_unlock_session.ts | 8 + src/stores/chat/helper/message-mappers.ts | 3 + src/stores/chat/ui-message.ts | 3 + .../__tests__/payment-order-flow.test.ts | 2 + src/stores/payment/helper/gift.ts | 2 + src/stores/payment/machine/catalog-flow.ts | 2 + src/stores/payment/payment-context.tsx | 2 + src/stores/payment/payment-state.ts | 3 + 54 files changed, 1950 insertions(+), 69 deletions(-) create mode 100644 src/app/chat/components/__tests__/chat-input-bar.test.tsx create mode 100644 src/app/chat/components/chat-composer-action-menu.module.css create mode 100644 src/app/chat/components/chat-composer-action-menu.tsx create mode 100644 src/app/chat/components/commercial-action-card.module.css create mode 100644 src/app/chat/components/commercial-action-card.tsx create mode 100644 src/app/private-room/components/relationship-diary-panel.module.css create mode 100644 src/app/private-room/components/relationship-diary-panel.tsx create mode 100644 src/data/schemas/chat/__tests__/commercial_action.test.ts create mode 100644 src/data/schemas/chat/commercial_action.ts create mode 100644 src/data/schemas/chat/request/tip_offer_action_request.ts create mode 100644 src/data/schemas/chat/response/tip_offer_action_response.ts create mode 100644 src/data/schemas/payment/gift_offer.ts create mode 100644 src/data/schemas/private-room/__tests__/relationship_diary.test.ts create mode 100644 src/data/schemas/private-room/relationship_diary.ts create mode 100644 src/data/schemas/private-room/request/unlock_relationship_diary_request.ts create mode 100644 src/data/schemas/private-room/response/relationship_diaries_response.ts create mode 100644 src/data/schemas/private-room/response/relationship_diary_seen_response.ts create mode 100644 src/data/schemas/private-room/response/relationship_diary_unlock_response.ts create mode 100644 src/lib/commercial/tip_offer_session.ts diff --git a/next.config.ts b/next.config.ts index 395266db..105e3254 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,12 +23,12 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "**.supabase.co", - pathname: "/storage/v1/object/public/**", + pathname: "/storage/v1/object/**", }, { protocol: "https", hostname: "dbapi.banlv-ai.com", - pathname: "/storage/v1/object/public/**", + pathname: "/storage/v1/object/**", }, { protocol: "https", diff --git a/src/app/chat/components/__tests__/chat-input-bar.test.tsx b/src/app/chat/components/__tests__/chat-input-bar.test.tsx new file mode 100644 index 00000000..53fcea2d --- /dev/null +++ b/src/app/chat/components/__tests__/chat-input-bar.test.tsx @@ -0,0 +1,174 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + dispatch: vi.fn(), +})); + +vi.mock("@/stores/chat/chat-context", () => ({ + useChatDispatch: () => mocks.dispatch, +})); +vi.mock("@/providers/character-provider", () => ({ + useActiveCharacter: () => ({ id: "maya-tan" }), + useActiveCharacterRoutes: () => ({ + tip: "/characters/maya/tip", + }), +})); +vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({ + useChatKeyboardAvoidance: () => undefined, +})); +vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({ + useChatKeyboardDiagnostics: () => undefined, +})); + +import { ChatInputBar } from "../chat-input-bar"; + +describe("ChatInputBar", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mocks.dispatch.mockReset(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("opens the ordered action menu, blurs input, and links to Tip", () => { + renderBar(); + const textarea = getTextarea(); + act(() => textarea.focus()); + expect(document.activeElement).toBe(textarea); + + act(() => getButton("Open chat actions").click()); + + expect(document.activeElement).not.toBe(textarea); + const menu = container.querySelector('[aria-label="Chat actions"]'); + expect(menu).not.toBeNull(); + expect( + Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()), + ).toEqual(["Image", "Voice", "Tip"]); + expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe( + "true", + ); + expect( + menu + ?.querySelector('[data-analytics-key="chat.open_tip"]') + ?.getAttribute("href"), + ).toBe("/characters/maya/tip"); + + act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); + expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); + }); + + it("injects image and voice promotions without persisting UI state", () => { + renderBar(); + + act(() => getButton("Open chat actions").click()); + act(() => getButton("Image").click()); + const imageEvent = mocks.dispatch.mock.calls[0]?.[0]; + expect(imageEvent).toMatchObject({ + type: "ChatPromotionInjected", + promotion: { + characterId: "maya-tan", + promotionType: "image", + lockType: "image_paywall", + }, + }); + expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/); + expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); + + act(() => getButton("Open chat actions").click()); + act(() => getButton("Voice").click()); + const voiceEvent = mocks.dispatch.mock.calls[1]?.[0]; + expect(voiceEvent).toMatchObject({ + type: "ChatPromotionInjected", + promotion: { + characterId: "maya-tan", + promotionType: "voice", + lockType: "voice_message", + }, + }); + expect(voiceEvent.promotion.clientLockId).not.toBe( + imageEvent.promotion.clientLockId, + ); + }); + + it("switches from actions to Send for non-whitespace input", () => { + renderBar(); + act(() => getButton("Open chat actions").click()); + + setTextareaValue(getTextarea(), "Hello Maya"); + expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); + expect(getButton("Send message")).not.toBeNull(); + + act(() => getButton("Send message").click()); + expect(mocks.dispatch).toHaveBeenCalledWith({ + type: "ChatSendMessage", + content: "Hello Maya", + }); + expect(getTextarea().value).toBe(""); + expect(document.activeElement).toBe(getTextarea()); + + setTextareaValue(getTextarea(), " "); + expect(getButton("Open chat actions")).not.toBeNull(); + }); + + it("closes the menu on outside interaction and when disabled", () => { + renderBar(); + act(() => getButton("Open chat actions").click()); + act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }))); + expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); + + act(() => getButton("Open chat actions").click()); + renderBar(true); + expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); + expect(getButton("Open chat actions").disabled).toBe(true); + }); + + function renderBar(disabled = false): void { + act(() => root.render()); + } + + function getTextarea(): HTMLTextAreaElement { + const textarea = container.querySelector("textarea"); + if (!textarea) throw new Error("Missing chat textarea"); + return textarea; + } + + function getButton(label: string): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll("button"), + ).find( + (item) => + item.getAttribute("aria-label") === label || + item.textContent?.trim() === label, + ); + if (!button) throw new Error(`Missing button: ${label}`); + return button; + } +}); + +function setTextareaValue( + textarea: HTMLTextAreaElement, + value: string, +): void { + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + setter?.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index 39eb48ad..243ae922 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -112,7 +112,10 @@ describe("chat Tailwind components", () => { undefined} + onMenuToggle={() => undefined} onPointerDownSend={() => undefined} />, ); @@ -120,7 +123,21 @@ describe("chat Tailwind components", () => { undefined} + onMenuToggle={() => undefined} + onPointerDownSend={() => undefined} + />, + ); + const openHtml = renderToStaticMarkup( + undefined} + onMenuToggle={() => undefined} onPointerDownSend={() => undefined} />, ); @@ -128,7 +145,10 @@ describe("chat Tailwind components", () => { undefined} + onMenuToggle={() => undefined} onPointerDownSend={() => undefined} />, ); @@ -136,12 +156,17 @@ describe("chat Tailwind components", () => { expect(activeHtml).toContain('aria-label="Send message"'); expect(activeHtml).toContain('data-analytics-ignore="true"'); expect(activeHtml).not.toContain("data-analytics-key"); - expect(activeHtml).toContain("size-(--chat-send-button-size,40px)"); + expect(activeHtml).toContain("size-(--chat-send-button-size,42px)"); expect(activeHtml).toContain( "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", ); - expect(emptyHtml).toContain("bg-[#f8a8ce]"); - expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]"); + expect(emptyHtml).toContain('aria-label="Open chat actions"'); + expect(emptyHtml).toContain('aria-expanded="false"'); + expect(emptyHtml).toContain('aria-controls="chat-actions"'); + expect(emptyHtml).toContain('data-analytics-key="chat.toggle_actions"'); + expect(openHtml).toContain('aria-label="Close chat actions"'); + expect(openHtml).toContain('aria-expanded="true"'); + expect(openHtml).toContain("bg-[#38262d]"); expect(disabledHtml).toContain("disabled"); }); diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 168370f5..6340e9f5 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -314,6 +314,7 @@ function renderMessagesWithDateHeaders( lockReason={item.message.lockReason} lockedPrivate={item.message.lockedPrivate} privateMessageHint={item.message.privateMessageHint} + commercialAction={item.message.commercialAction} isUnlockingMessage={ isUnlockingMessage === true && item.message.displayId === unlockingMessageId diff --git a/src/app/chat/components/chat-composer-action-menu.module.css b/src/app/chat/components/chat-composer-action-menu.module.css new file mode 100644 index 00000000..5ec4e8e2 --- /dev/null +++ b/src/app/chat/components/chat-composer-action-menu.module.css @@ -0,0 +1,148 @@ +.menu { + position: absolute; + right: 0; + bottom: calc(100% + 10px); + z-index: 4; + display: grid; + width: min(100%, 348px); + box-sizing: border-box; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 9px; + border: 1px solid rgba(77, 48, 57, 0.09); + border-radius: 24px; + background: rgba(255, 255, 255, 0.95); + box-shadow: + 0 22px 54px rgba(74, 45, 55, 0.16), + 0 4px 14px rgba(74, 45, 55, 0.08); + backdrop-filter: blur(20px); + transform-origin: right bottom; +} + +.action { + display: flex; + min-width: 0; + min-height: 76px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 6px; + border: 0; + border-radius: 18px; + background: #faf7f8; + color: #543d45; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 850; + line-height: 1; + text-decoration: none; + transition: + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease; +} + +.action:disabled, +.action[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.48; +} + +.icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 14px; +} + +.imageIcon { + background: #fff0f4; + color: #d94f7c; +} + +.voiceIcon { + background: #fff4e8; + color: #d87343; +} + +.tipIcon { + background: #f7f0e8; + color: #986a42; +} + +.action:focus-visible { + outline: 3px solid rgba(246, 87, 160, 0.28); + outline-offset: 2px; +} + +@media (hover: hover) { + .action:not(:disabled):not([aria-disabled="true"]):hover { + background: #fff0f5; + color: #b83d69; + transform: translateY(-1px); + } +} + +@media (max-width: 350px) { + .menu { + gap: 6px; + padding: 7px; + border-radius: 21px; + } + + .action { + min-height: 70px; + border-radius: 16px; + font-size: 11px; + } + + .icon { + width: 35px; + height: 35px; + } +} + +@media (prefers-reduced-motion: no-preference) { + .menu { + animation: menuReveal 0.22s cubic-bezier(0.2, 0.8, 0.3, 1) both; + } + + .action { + animation: actionReveal 0.25s ease both; + } + + .action:nth-child(2) { + animation-delay: 35ms; + } + + .action:nth-child(3) { + animation-delay: 70ms; + } +} + +@keyframes menuReveal { + from { + opacity: 0; + transform: translateY(7px) scale(0.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes actionReveal { + from { + opacity: 0; + transform: translateY(5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/chat/components/chat-composer-action-menu.tsx b/src/app/chat/components/chat-composer-action-menu.tsx new file mode 100644 index 00000000..32c615c8 --- /dev/null +++ b/src/app/chat/components/chat-composer-action-menu.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ImagePlus, Mic2, Coffee } from "lucide-react"; +import Link from "next/link"; + +import styles from "./chat-composer-action-menu.module.css"; + +export interface ChatComposerActionMenuProps { + id: string; + disabled?: boolean; + tipHref: string; + onImage: () => void; + onVoice: () => void; + onNavigate: () => void; +} + +export function ChatComposerActionMenu({ + id, + disabled = false, + tipHref, + onImage, + onVoice, + onNavigate, +}: ChatComposerActionMenuProps) { + return ( +
+ + + { + if (disabled) { + event.preventDefault(); + return; + } + onNavigate(); + }} + > + + + Tip + +
+ ); +} diff --git a/src/app/chat/components/chat-input-bar.module.css b/src/app/chat/components/chat-input-bar.module.css index 92806a97..67cc0438 100644 --- a/src/app/chat/components/chat-input-bar.module.css +++ b/src/app/chat/components/chat-input-bar.module.css @@ -1,10 +1,10 @@ -/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */ - /* Keyboard inset is a total bottom inset, not an addition to normal spacing. */ .bar { + position: relative; + z-index: 5; flex: 0 0 auto; - padding: 0 + padding: clamp(7px, 1.852vw, 10px) calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) max( calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)), @@ -12,28 +12,43 @@ ) calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px)); background: transparent; - transition: padding-top 0.2s ease, padding-bottom 0.2s ease, - background-color 0.2s ease, box-shadow 0.2s ease; + transition: padding-bottom 0.2s ease; } -.barFocused { - padding-top: var(--spacing-md, 12px); - background: #feeff2; - box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1)); +.composer { + position: relative; + width: 100%; } -/* 内层:白底 + 大圆角(Dart AppRadius.radius32)+ focused 时 accent 边框 */ .row { display: flex; align-items: center; - gap: var(--spacing-sm, 8px); - padding: var(--spacing-sm, 8px); - background: #fff; + gap: 6px; + padding: 6px; + border: 1px solid rgba(94, 62, 73, 0.11); border-radius: var(--radius-full, 999px); - border: 1px solid transparent; - transition: border-color 0.2s ease; + background: rgba(255, 255, 255, 0.94); + box-shadow: + 0 12px 30px rgba(75, 48, 57, 0.1), + 0 2px 8px rgba(75, 48, 57, 0.05); + backdrop-filter: blur(18px); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background 0.2s ease; } .rowFocused { - border-color: var(--color-accent, #f84d96); + border-color: rgba(246, 87, 160, 0.62); + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 0 0 3px rgba(246, 87, 160, 0.1), + 0 14px 34px rgba(92, 52, 67, 0.12); +} + +@media (max-width: 350px) { + .bar { + padding-right: calc(12px + var(--app-safe-right, 0px)); + padding-left: calc(12px + var(--app-safe-left, 0px)); + } } diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index 3bd85259..205aab3b 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -1,17 +1,25 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session"; +import { recordTipOfferAction } from "@/lib/commercial/tip_offer_session"; +import { + useActiveCharacter, + useActiveCharacterRoutes, +} from "@/providers/character-provider"; import { useChatDispatch } from "@/stores/chat/chat-context"; import { Logger } from "@/utils/logger"; import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance"; import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics"; +import { ChatComposerActionMenu } from "./chat-composer-action-menu"; import { ChatInputTextField } from "./chat-input-text-field"; import { ChatSendButton } from "./chat-send-button"; import styles from "./chat-input-bar.module.css"; const log = new Logger("AppChatComponentsChatInputBar"); const POINTER_SEND_CLICK_DEDUPE_MS = 500; +const CHAT_ACTION_MENU_ID = "chat-composer-action-menu"; export interface ChatInputBarProps { disabled?: boolean; @@ -19,22 +27,57 @@ export interface ChatInputBarProps { export function ChatInputBar({ disabled = false }: ChatInputBarProps) { const dispatch = useChatDispatch(); + const character = useActiveCharacter(); + const characterRoutes = useActiveCharacterRoutes(); const [input, setInput] = useState(""); const [isFocused, setIsFocused] = useState(false); + const [isActionMenuOpen, setIsActionMenuOpen] = useState(false); + const [previousDisabled, setPreviousDisabled] = useState(disabled); const barRef = useRef(null); const textareaRef = useRef(null); const lastPointerSendAtRef = useRef(0); const hasContent = input.trim().length > 0; + if (disabled !== previousDisabled) { + setPreviousDisabled(disabled); + if (disabled && isActionMenuOpen) setIsActionMenuOpen(false); + } + useChatKeyboardAvoidance({ active: isFocused, containerRef: barRef, }); useChatKeyboardDiagnostics({ containerRef: barRef }); + useEffect(() => { + if (!isActionMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && barRef.current?.contains(target)) return; + setIsActionMenuOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsActionMenuOpen(false); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isActionMenuOpen]); + const handleInputChange = (value: string) => { setInput(value); + if (value.trim().length > 0) setIsActionMenuOpen(false); + }; + + const handleFocusChange = (focused: boolean) => { + setIsFocused(focused); + if (focused) setIsActionMenuOpen(false); }; const handleSend = (source: "click" | "keyboard" | "pointerdown") => { @@ -62,6 +105,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) { }); dispatch({ type: "ChatSendMessage", content: input }); setInput(""); + setIsActionMenuOpen(false); textareaRef.current?.focus(); }; @@ -70,26 +114,61 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) { handleSend("pointerdown"); }; + const handleMenuToggle = () => { + if (disabled || hasContent) return; + if (!isActionMenuOpen) textareaRef.current?.blur(); + setIsActionMenuOpen((open) => !open); + }; + + const handlePromotion = (promotionType: "image" | "voice") => { + if (disabled) return; + dispatch({ + type: "ChatPromotionInjected", + promotion: createPendingChatPromotion( + promotionType, + character.id, + ), + }); + setIsActionMenuOpen(false); + }; + return ( -
-
- handleSend("keyboard")} - onFocusChange={setIsFocused} - disabled={disabled} - /> - handleSend("click")} - onPointerDownSend={handlePointerDownSend} - /> +
+
+ {isActionMenuOpen ? ( + handlePromotion("image")} + onVoice={() => handlePromotion("voice")} + onNavigate={() => { + recordTipOfferAction(character.id, "opened", "manualMenu"); + setIsActionMenuOpen(false); + }} + /> + ) : null} +
+ handleSend("keyboard")} + onFocusChange={handleFocusChange} + disabled={disabled} + /> + handleSend("click")} + onMenuToggle={handleMenuToggle} + onPointerDownSend={handlePointerDownSend} + /> +
); diff --git a/src/app/chat/components/chat-input-text-field.tsx b/src/app/chat/components/chat-input-text-field.tsx index e3afb4c7..6c360fc1 100644 --- a/src/app/chat/components/chat-input-text-field.tsx +++ b/src/app/chat/components/chat-input-text-field.tsx @@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef< }; return ( -
+