feat(chat): add composer action menu
This commit is contained in:
@@ -1,17 +1,24 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, 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;
|
||||
@@ -19,22 +26,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<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") => {
|
||||
@@ -62,6 +104,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
});
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
setIsActionMenuOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
@@ -70,26 +113,58 @@ 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} ${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 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user