Revert "feat(chat): add composer action menu"
This reverts commit 7789673fff.
This commit is contained in:
@@ -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<HTMLAnchorElement>('[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(<ChatInputBar disabled={disabled} />));
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>("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 }));
|
||||
});
|
||||
}
|
||||
@@ -91,10 +91,7 @@ describe("chat Tailwind components", () => {
|
||||
<ChatSendButton
|
||||
disabled={false}
|
||||
hasContent={true}
|
||||
isMenuOpen={false}
|
||||
menuId="chat-actions"
|
||||
onClick={() => undefined}
|
||||
onMenuToggle={() => undefined}
|
||||
onPointerDownSend={() => undefined}
|
||||
/>,
|
||||
);
|
||||
@@ -102,21 +99,7 @@ describe("chat Tailwind components", () => {
|
||||
<ChatSendButton
|
||||
disabled={false}
|
||||
hasContent={false}
|
||||
isMenuOpen={false}
|
||||
menuId="chat-actions"
|
||||
onClick={() => undefined}
|
||||
onMenuToggle={() => undefined}
|
||||
onPointerDownSend={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const openHtml = renderToStaticMarkup(
|
||||
<ChatSendButton
|
||||
disabled={false}
|
||||
hasContent={false}
|
||||
isMenuOpen={true}
|
||||
menuId="chat-actions"
|
||||
onClick={() => undefined}
|
||||
onMenuToggle={() => undefined}
|
||||
onPointerDownSend={() => undefined}
|
||||
/>,
|
||||
);
|
||||
@@ -124,10 +107,7 @@ describe("chat Tailwind components", () => {
|
||||
<ChatSendButton
|
||||
disabled={true}
|
||||
hasContent={true}
|
||||
isMenuOpen={false}
|
||||
menuId="chat-actions"
|
||||
onClick={() => 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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div id={id} className={styles.menu} aria-label="Chat actions">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
disabled={disabled}
|
||||
data-analytics-key="chat.promotion_image"
|
||||
onClick={onImage}
|
||||
>
|
||||
<span className={`${styles.icon} ${styles.imageIcon}`}>
|
||||
<ImagePlus size={21} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<span>Image</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
disabled={disabled}
|
||||
data-analytics-key="chat.promotion_voice"
|
||||
onClick={onVoice}
|
||||
>
|
||||
<span className={`${styles.icon} ${styles.voiceIcon}`}>
|
||||
<Mic2 size={21} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<span>Voice</span>
|
||||
</button>
|
||||
<Link
|
||||
href={tipHref}
|
||||
className={styles.action}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
data-analytics-key="chat.open_tip"
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onNavigate();
|
||||
}}
|
||||
>
|
||||
<span className={`${styles.icon} ${styles.tipIcon}`}>
|
||||
<Coffee size={21} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<span>Tip</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<div ref={barRef} className={styles.bar}>
|
||||
<div className={styles.composer}>
|
||||
{isActionMenuOpen ? (
|
||||
<ChatComposerActionMenu
|
||||
id={CHAT_ACTION_MENU_ID}
|
||||
disabled={disabled}
|
||||
tipHref={characterRoutes.tip}
|
||||
onImage={() => handlePromotion("image")}
|
||||
onVoice={() => handlePromotion("voice")}
|
||||
onNavigate={() => setIsActionMenuOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
|
||||
>
|
||||
<ChatInputTextField
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onSubmit={() => handleSend("keyboard")}
|
||||
onFocusChange={handleFocusChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ChatSendButton
|
||||
disabled={disabled}
|
||||
hasContent={hasContent}
|
||||
isMenuOpen={isActionMenuOpen}
|
||||
menuId={CHAT_ACTION_MENU_ID}
|
||||
onClick={() => handleSend("click")}
|
||||
onMenuToggle={handleMenuToggle}
|
||||
onPointerDownSend={handlePointerDownSend}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={barRef}
|
||||
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
|
||||
>
|
||||
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
||||
<ChatInputTextField
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onSubmit={() => handleSend("keyboard")}
|
||||
onFocusChange={setIsFocused}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ChatSendButton
|
||||
disabled={disabled}
|
||||
hasContent={hasContent}
|
||||
onClick={() => handleSend("click")}
|
||||
onPointerDownSend={handlePointerDownSend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef<
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-auto items-center rounded-full bg-transparent px-[clamp(10px,3vw,14px)]">
|
||||
<div className="flex min-w-0 flex-auto items-center rounded-full bg-white px-(--spacing-lg,16px)">
|
||||
<textarea
|
||||
ref={innerRef}
|
||||
className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)"
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
"use client";
|
||||
import { ArrowUp, Plus, X } from "lucide-react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
export interface ChatSendButtonProps {
|
||||
disabled: boolean;
|
||||
hasContent: boolean;
|
||||
isMenuOpen: boolean;
|
||||
menuId: string;
|
||||
onClick: () => void;
|
||||
onMenuToggle: () => void;
|
||||
onPointerDownSend: () => void;
|
||||
}
|
||||
|
||||
export function ChatSendButton({
|
||||
disabled,
|
||||
hasContent,
|
||||
isMenuOpen,
|
||||
menuId,
|
||||
onClick,
|
||||
onMenuToggle,
|
||||
onPointerDownSend,
|
||||
}: ChatSendButtonProps) {
|
||||
const isSendMode = hasContent;
|
||||
const label = isSendMode
|
||||
? "Send message"
|
||||
: isMenuOpen
|
||||
? "Close chat actions"
|
||||
: "Open chat actions";
|
||||
const isActive = hasContent && !disabled;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-ignore={isSendMode ? true : undefined}
|
||||
data-analytics-key={isSendMode ? undefined : "chat.toggle_actions"}
|
||||
data-analytics-ignore
|
||||
className={[
|
||||
"flex aspect-square size-(--chat-send-button-size,42px) shrink-0 cursor-pointer items-center justify-center rounded-full border transition-[background,color,transform,box-shadow] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f657a0]",
|
||||
isSendMode
|
||||
? "border-transparent bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-white shadow-[0_8px_20px_rgba(246,87,160,0.3)]"
|
||||
: isMenuOpen
|
||||
? "border-transparent bg-[#38262d] text-white shadow-[0_8px_18px_rgba(56,38,45,0.18)]"
|
||||
: "border-[rgba(104,67,80,0.09)] bg-[#f8f1f4] text-[#76505f] shadow-none",
|
||||
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
||||
isActive
|
||||
? "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]"
|
||||
: "bg-[#f8a8ce] text-[rgba(255,255,255,0.88)] shadow-none",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
@@ -46,21 +32,17 @@ export function ChatSendButton({
|
||||
if (event.pointerType === "mouse") return;
|
||||
if (disabled) return;
|
||||
event.preventDefault();
|
||||
if (!isSendMode) return;
|
||||
if (!hasContent) return;
|
||||
onPointerDownSend();
|
||||
}}
|
||||
onClick={isSendMode ? onClick : onMenuToggle}
|
||||
aria-label={label}
|
||||
aria-expanded={isSendMode ? undefined : isMenuOpen}
|
||||
aria-controls={isSendMode ? undefined : menuId}
|
||||
onClick={onClick}
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isSendMode ? (
|
||||
<ArrowUp size={23} strokeWidth={2.4} aria-hidden="true" />
|
||||
) : isMenuOpen ? (
|
||||
<X size={22} strokeWidth={2.2} aria-hidden="true" />
|
||||
) : (
|
||||
<Plus size={23} strokeWidth={2.2} aria-hidden="true" />
|
||||
)}
|
||||
<ArrowUp
|
||||
className="size-(--icon-size-xl,24px) text-(length:--icon-size-xl,24px) leading-none"
|
||||
size={24}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user