fix(chat): keep mobile send from losing focus
This commit is contained in:
@@ -10,6 +10,7 @@ import { ChatSendButton } from "./chat-send-button";
|
|||||||
import styles from "./chat-input-bar.module.css";
|
import styles from "./chat-input-bar.module.css";
|
||||||
|
|
||||||
const log = new Logger("AppChatComponentsChatInputBar");
|
const log = new Logger("AppChatComponentsChatInputBar");
|
||||||
|
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
|
||||||
|
|
||||||
export interface ChatInputBarProps {
|
export interface ChatInputBarProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -19,12 +20,15 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
const dispatch = useChatDispatch();
|
const dispatch = useChatDispatch();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const lastPointerSendAtRef = useRef(0);
|
||||||
|
|
||||||
const hasContent = input.trim().length > 0;
|
const hasContent = input.trim().length > 0;
|
||||||
|
|
||||||
useKeyboardHeight({
|
useKeyboardHeight({
|
||||||
targetRef: textareaRef,
|
targetRef: textareaRef,
|
||||||
|
containerRef: barRef,
|
||||||
active: isFocused,
|
active: isFocused,
|
||||||
onKeyboardDismiss: () => {
|
onKeyboardDismiss: () => {
|
||||||
log.important("info", "[chat-input-bar] keyboard dismissed", {
|
log.important("info", "[chat-input-bar] keyboard dismissed", {
|
||||||
@@ -51,9 +55,23 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
setIsFocused(focused);
|
setIsFocused(focused);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
|
||||||
if (!hasContent) return;
|
if (source === "click") {
|
||||||
|
const elapsed = Date.now() - lastPointerSendAtRef.current;
|
||||||
|
if (elapsed >= 0 && elapsed < POINTER_SEND_CLICK_DEDUPE_MS) {
|
||||||
|
log.important("info", "[chat-input-bar] suppress duplicate click", {
|
||||||
|
elapsed,
|
||||||
|
contentLength: input.length,
|
||||||
|
hasContent,
|
||||||
|
disabled,
|
||||||
|
isFocused,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disabled || !hasContent) return;
|
||||||
log.debug("[chat-input-bar] handleSend", {
|
log.debug("[chat-input-bar] handleSend", {
|
||||||
|
source,
|
||||||
contentLength: input.length,
|
contentLength: input.length,
|
||||||
contentPreview: input.slice(0, 50),
|
contentPreview: input.slice(0, 50),
|
||||||
hasContent,
|
hasContent,
|
||||||
@@ -61,6 +79,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
isFocused,
|
isFocused,
|
||||||
});
|
});
|
||||||
log.important("info", "[chat-input-bar] send", {
|
log.important("info", "[chat-input-bar] send", {
|
||||||
|
source,
|
||||||
contentLength: input.length,
|
contentLength: input.length,
|
||||||
hasContent,
|
hasContent,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -71,21 +90,30 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePointerDownSend = () => {
|
||||||
|
lastPointerSendAtRef.current = Date.now();
|
||||||
|
handleSend("pointerdown");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}>
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
|
||||||
|
>
|
||||||
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
||||||
<ChatInputTextField
|
<ChatInputTextField
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onSubmit={handleSend}
|
onSubmit={() => handleSend("keyboard")}
|
||||||
onFocusChange={handleFocusChange}
|
onFocusChange={handleFocusChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ChatSendButton
|
<ChatSendButton
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
hasContent={hasContent}
|
hasContent={hasContent}
|
||||||
onClick={handleSend}
|
onClick={() => handleSend("click")}
|
||||||
|
onPointerDownSend={handlePointerDownSend}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ export interface ChatSendButtonProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
hasContent: boolean;
|
hasContent: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onPointerDownSend: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatSendButton({
|
export function ChatSendButton({
|
||||||
disabled,
|
disabled,
|
||||||
hasContent,
|
hasContent,
|
||||||
onClick,
|
onClick,
|
||||||
|
onPointerDownSend,
|
||||||
}: ChatSendButtonProps) {
|
}: ChatSendButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -32,6 +34,13 @@ export function ChatSendButton({
|
|||||||
hasContent && !disabled ? styles.buttonActive : styles.buttonEmpty
|
hasContent && !disabled ? styles.buttonActive : styles.buttonEmpty
|
||||||
}`}
|
}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.pointerType === "mouse") return;
|
||||||
|
if (disabled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!hasContent) return;
|
||||||
|
onPointerDownSend();
|
||||||
|
}}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ interface KeyboardSnapshot {
|
|||||||
|
|
||||||
export function useKeyboardHeight({
|
export function useKeyboardHeight({
|
||||||
targetRef,
|
targetRef,
|
||||||
|
containerRef,
|
||||||
active = false,
|
active = false,
|
||||||
onKeyboardDismiss,
|
onKeyboardDismiss,
|
||||||
}: UseKeyboardHeightOptions = {}): void {
|
}: UseKeyboardHeightOptions = {}): void {
|
||||||
@@ -172,6 +173,13 @@ export function useKeyboardHeight({
|
|||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent | TouchEvent) => {
|
const handlePointerDown = (event: PointerEvent | TouchEvent) => {
|
||||||
const target = targetRef?.current;
|
const target = targetRef?.current;
|
||||||
|
const container = containerRef?.current;
|
||||||
|
if (event.target instanceof Node && container?.contains(event.target)) {
|
||||||
|
log.important("info", "[keyboard] keep active for input container tap", {
|
||||||
|
snapshot: formatSnapshot(readKeyboardSnapshot()),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!target || event.target === target) return;
|
if (!target || event.target === target) return;
|
||||||
if (event.target instanceof Node && target.contains(event.target)) return;
|
if (event.target instanceof Node && target.contains(event.target)) return;
|
||||||
release("outside-interaction");
|
release("outside-interaction");
|
||||||
@@ -215,6 +223,7 @@ export function useKeyboardHeight({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
active,
|
active,
|
||||||
|
containerRef,
|
||||||
targetRef,
|
targetRef,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
|
|||||||
|
|
||||||
export interface UseKeyboardHeightOptions {
|
export interface UseKeyboardHeightOptions {
|
||||||
targetRef?: RefObject<HTMLElement | null>;
|
targetRef?: RefObject<HTMLElement | null>;
|
||||||
|
containerRef?: RefObject<HTMLElement | null>;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onKeyboardDismiss?: () => void;
|
onKeyboardDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user