From e512a42483a5130a82639065438a46adb44e9285 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 21 Jul 2026 11:04:39 +0800 Subject: [PATCH] Revert "feat(chat): add composer action menu" This reverts commit 7789673fff5ea9f1d4f4d93ac08ede462c0ca007. --- .../__tests__/chat-input-bar.test.tsx | 174 ------------------ .../__tests__/tailwind-components.test.tsx | 31 +--- .../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 | 115 ++---------- .../chat/components/chat-input-text-field.tsx | 2 +- src/app/chat/components/chat-send-button.tsx | 48 ++--- .../__tests__/navigation_storage.test.ts | 27 +-- .../storage/navigation/navigation_storage.ts | 22 +-- src/lib/navigation/chat_unlock_session.ts | 8 - 11 files changed, 62 insertions(+), 632 deletions(-) delete mode 100644 src/app/chat/components/__tests__/chat-input-bar.test.tsx delete mode 100644 src/app/chat/components/chat-composer-action-menu.module.css delete mode 100644 src/app/chat/components/chat-composer-action-menu.tsx diff --git a/src/app/chat/components/__tests__/chat-input-bar.test.tsx b/src/app/chat/components/__tests__/chat-input-bar.test.tsx deleted file mode 100644 index 53fcea2d..00000000 --- a/src/app/chat/components/__tests__/chat-input-bar.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* @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 fc5574c0..2626bd17 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -91,10 +91,7 @@ describe("chat Tailwind components", () => { undefined} - onMenuToggle={() => undefined} onPointerDownSend={() => undefined} />, ); @@ -102,21 +99,7 @@ describe("chat Tailwind components", () => { undefined} - onMenuToggle={() => undefined} - onPointerDownSend={() => undefined} - />, - ); - const openHtml = renderToStaticMarkup( - undefined} - onMenuToggle={() => undefined} onPointerDownSend={() => undefined} />, ); @@ -124,10 +107,7 @@ describe("chat Tailwind components", () => { undefined} - onMenuToggle={() => undefined} onPointerDownSend={() => undefined} />, ); @@ -135,17 +115,12 @@ 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,42px)"); + expect(activeHtml).toContain("size-(--chat-send-button-size,40px)"); expect(activeHtml).toContain( "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", ); - 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(emptyHtml).toContain("bg-[#f8a8ce]"); + expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]"); expect(disabledHtml).toContain("disabled"); }); diff --git a/src/app/chat/components/chat-composer-action-menu.module.css b/src/app/chat/components/chat-composer-action-menu.module.css deleted file mode 100644 index 5ec4e8e2..00000000 --- a/src/app/chat/components/chat-composer-action-menu.module.css +++ /dev/null @@ -1,148 +0,0 @@ -.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 deleted file mode 100644 index 32c615c8..00000000 --- a/src/app/chat/components/chat-composer-action-menu.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"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 67cc0438..92806a97 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: clamp(7px, 1.852vw, 10px) + padding: 0 calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) max( calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)), @@ -12,43 +12,28 @@ ) calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px)); background: transparent; - transition: padding-bottom 0.2s ease; + transition: padding-top 0.2s ease, padding-bottom 0.2s ease, + background-color 0.2s ease, box-shadow 0.2s ease; } -.composer { - position: relative; - width: 100%; +.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)); } +/* 内层:白底 + 大圆角(Dart AppRadius.radius32)+ focused 时 accent 边框 */ .row { display: flex; align-items: center; - gap: 6px; - padding: 6px; - border: 1px solid rgba(94, 62, 73, 0.11); + gap: var(--spacing-sm, 8px); + padding: var(--spacing-sm, 8px); + background: #fff; border-radius: var(--radius-full, 999px); - 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; + border: 1px solid transparent; + transition: border-color 0.2s ease; } .rowFocused { - 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)); - } + border-color: var(--color-accent, #f84d96); } diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index b20840de..3bd85259 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -1,24 +1,17 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; -import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_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; @@ -26,57 +19,22 @@ 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") => { @@ -104,7 +62,6 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) { }); dispatch({ type: "ChatSendMessage", content: input }); setInput(""); - setIsActionMenuOpen(false); textareaRef.current?.focus(); }; @@ -113,58 +70,26 @@ 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 ( -
-
- {isActionMenuOpen ? ( - handlePromotion("image")} - onVoice={() => handlePromotion("voice")} - onNavigate={() => setIsActionMenuOpen(false)} - /> - ) : null} -
- handleSend("keyboard")} - onFocusChange={handleFocusChange} - disabled={disabled} - /> - handleSend("click")} - onMenuToggle={handleMenuToggle} - onPointerDownSend={handlePointerDownSend} - /> -
+
+
+ handleSend("keyboard")} + onFocusChange={setIsFocused} + disabled={disabled} + /> + handleSend("click")} + 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 6c360fc1..e3afb4c7 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 ( -
+