Files
backspace/packages/web/src/components/chat/MessageInput.tsx
T
Jannis Braun 0aba5ef8c1 fix: retain textarea focus on send and auto-scroll on content expansion
Remove disabled={isUploading} from the textarea so focus() isn't silently
ignored on a disabled element. Fix ResizeObserver effect deps so the
observer is actually created after the loading spinner transitions to
message content.
2026-03-08 21:01:27 +01:00

370 lines
15 KiB
TypeScript

import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
import type { MemberWithUser } from '@backspace/shared';
interface MessageInputProps {
channelId: string;
channelName: string;
}
interface MentionState {
query: string;
startIndex: number;
selectedIndex: number;
}
export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [content, setContent] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Auto-focus textarea on channel navigation
useEffect(() => {
textareaRef.current?.focus();
}, [channelId]);
// Auto-focus textarea when replying
useEffect(() => {
if (replyTo) {
textareaRef.current?.focus();
}
}, [replyTo]);
// Filter members for the mention popover
const filteredMembers = useMemo(() => {
if (!mentionState) return [];
const q = mentionState.query.toLowerCase();
return members
.filter((m) => {
const name = (m.user.displayName ?? m.user.username).toLowerCase();
const username = m.user.username.toLowerCase();
return name.includes(q) || username.includes(q);
})
.slice(0, 8);
}, [members, mentionState]);
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return;
const isDm = isDmChannel(channelId);
if (isDm) {
wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId));
} else {
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
}
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0) return;
setIsUploading(true);
setMentionState(null);
try {
// Upload files first — route to the correct instance for this channel
const attachmentIds: string[] = [];
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
for (const file of files) {
const attachment = await uploadClient.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Reset textarea height and re-focus
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.focus();
}
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsUploading(false);
}
};
const selectMention = useCallback((member: MemberWithUser) => {
if (!mentionState) return;
const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? content.length;
const before = content.slice(0, mentionState.startIndex);
const after = content.slice(cursorPos);
const insertion = `<@${member.userId}> `;
const newContent = before + insertion + after;
setContent(newContent);
setMentionState(null);
// Restore cursor position after React re-renders
const newCursorPos = before.length + insertion.length;
requestAnimationFrame(() => {
if (textarea) {
textarea.focus();
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
}
});
}, [mentionState, content]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention popover keyboard navigation
if (mentionState && filteredMembers.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionState((prev) =>
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null
);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionState((prev) =>
prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null
);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const selected = filteredMembers[mentionState.selectedIndex];
if (selected) selectMention(selected);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionState(null);
return;
}
}
// Default: Enter to submit
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
const pastedFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart;
setContent(value);
// Detect @mention trigger
const textBeforeCursor = value.slice(0, cursorPos);
const mentionMatch = textBeforeCursor.match(/@([^\s<]*)$/);
if (mentionMatch) {
const atIndex = cursorPos - mentionMatch[0].length;
// Only trigger at word boundary: start of input, after space, or after newline
const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined;
if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') {
setMentionState({
query: mentionMatch[1]!,
startIndex: atIndex,
selectedIndex: 0,
});
} else {
setMentionState(null);
}
} else {
setMentionState(null);
}
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<TypingIndicator channelId={channelId} />
{replyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
<span className="opacity-60">Replying to</span>
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
</div>
<button
onClick={() => setReplyTo(null)}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
)}
<div
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Mention autocomplete popover */}
{mentionState && filteredMembers.length > 0 && (
<MentionPopover
query={mentionState.query}
selectedIndex={mentionState.selectedIndex}
onSelect={selectMention}
/>
)}
{/* File previews */}
{files.length > 0 && (
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
{files.map((file, i) => (
<div key={i} className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="max-h-[150px] rounded object-cover"
/>
) : (
<div className="flex items-center gap-2 text-sm text-txt-secondary py-4 px-2">
<svg className="w-8 h-8 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
</div>
)}
<button
onClick={() => removeFile(i)}
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
</svg>
</button>
</div>
))}
</div>
)}
<div className="flex items-center pl-[10px] pr-1">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
}}
/>
{/* Text input */}
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
className="flex-1 py-[10px] px-1 bg-transparent text-txt-primary placeholder-txt-tertiary/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
/>
{/* Send indicator */}
{isUploading && (
<div className="p-3 text-txt-tertiary">
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* GIF button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="GIF">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</svg>
</button>
{/* Sticker button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="Stickers">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" />
</svg>
</button>
{/* Emoji button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="Emoji">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
</button>
</div>
</div>
</div>
);
}