Files
backspace/packages/web/src/components/chat/MessageInput.tsx
T
Jannis Braun 9a5e613331 feat: add upload progress bars and error toasts
Adds uploadWithProgress() using XMLHttpRequest for real-time upload
progress events. MessageInput now shows per-file progress bars with
percentage overlay during upload. Failed uploads show toast warnings
with the specific error instead of silently failing. Upload timeout
raised to 10 minutes for large files.
2026-03-23 02:51:56 +01:00

527 lines
21 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 { InputPopover, type InputPopoverTab } from './InputPopover';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
import { useSettingsStore } from '../../stores/settingsStore';
import { useUIStore } from '../../stores/uiStore';
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 [uploadProgress, setUploadProgress] = useState<Map<number, number>>(new Map());
const addToast = useUIStore((s) => s.addToast);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const popoverAnchorRef = useRef<HTMLDivElement>(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>>();
// Feature flags
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
const isDm = isDmChannel(channelId);
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
// Auto-focus textarea on channel navigation
useEffect(() => {
textareaRef.current?.focus();
}, [channelId]);
// Auto-focus textarea when replying
useEffect(() => {
if (replyTo) {
textareaRef.current?.focus();
}
}, [replyTo]);
// Close popover on channel change
useEffect(() => {
setActivePopover(null);
}, [channelId]);
// 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 remaining = MAX_MESSAGE_LENGTH - content.length;
const isOverLimit = remaining < 0;
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0) return;
if (isOverLimit) return;
setIsUploading(true);
setMentionState(null);
setActivePopover(null);
setUploadProgress(new Map());
try {
// Upload files first — route to the correct instance for this channel
const attachmentIds: string[] = [];
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
const failedFiles: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i]!;
try {
const attachment = await uploadClient.uploads.uploadWithProgress(file, (loaded, total) => {
setUploadProgress(prev => new Map(prev).set(i, Math.round((loaded / total) * 100)));
});
attachmentIds.push(attachment.id);
setUploadProgress(prev => new Map(prev).set(i, 100));
} catch (err) {
failedFiles.push(file.name);
const msg = err instanceof Error ? err.message : 'Upload failed';
addToast(`Failed to upload ${file.name}: ${msg}`, 'warning');
}
}
if (failedFiles.length > 0 && attachmentIds.length === 0 && !trimmed) {
// All uploads failed, no text — nothing to send
return;
}
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) {
const msg = err instanceof Error ? err.message : 'Failed to send message';
addToast(msg, 'warning');
} finally {
setIsUploading(false);
setUploadProgress(new Map());
}
};
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';
};
const handleEmojiSelect = useCallback((emoji: { native: string }) => {
const textarea = textareaRef.current;
if (!textarea) {
setContent((prev) => prev + emoji.native);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = content.slice(0, start);
const after = content.slice(end);
const newContent = before + emoji.native + after;
setContent(newContent);
// Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
});
}, [content]);
const handleGifSelect = useCallback((url: string) => {
setActivePopover(null);
sendMessage(channelId, url);
}, [channelId, sendMessage]);
const togglePopover = useCallback((tab: InputPopoverTab) => {
setActivePopover((prev) => prev === tab ? null : tab);
}, []);
const canSend = (content.trim() || files.length > 0) && !isOverLimit && !isUploading;
if (!canSendMessages) {
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]">
<div className="flex items-center justify-center py-[14px] px-4">
<span className="text-txt-tertiary text-[14px]">You do not have permission to send messages in this channel</span>
</div>
</div>
);
}
return (
<div ref={popoverAnchorRef} 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} />
{/* Input popover (emoji / gif) */}
{activePopover && (
<InputPopover
activeTab={activePopover}
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
onTabChange={setActivePopover}
/>
)}
{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
ref={inputContainerRef}
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
onDrop={canAttachFiles ? handleDrop : undefined}
onDragOver={canAttachFiles ? handleDragOver : undefined}
>
{/* Mention autocomplete popover */}
{mentionState && filteredMembers.length > 0 && (
<MentionPopover
query={mentionState.query}
selectedIndex={mentionState.selectedIndex}
onSelect={selectMention}
anchorRef={inputContainerRef}
/>
)}
{/* File previews */}
{files.length > 0 && (
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
{files.map((file, i) => {
const progress = uploadProgress.get(i);
return (
<div key={i} className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard overflow-hidden">
{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>
)}
{/* Upload progress bar */}
{progress !== undefined && progress < 100 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10">
<div
className="h-full bg-accent-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
)}
{progress !== undefined && progress < 100 && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center rounded-lg">
<span className="text-xs font-medium text-white">{progress}%</span>
</div>
)}
{!isUploading && (
<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 */}
{canAttachFiles && (
<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={canAttachFiles ? handlePaste : undefined}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
className="input-embedded flex-1 py-[10px] px-1 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>
)}
{/* Character counter (shows when near or over limit) */}
{content.length > MAX_MESSAGE_LENGTH - 200 && (
<span className={`text-[12px] font-medium tabular-nums flex-shrink-0 px-1 ${isOverLimit ? 'text-accent-rose' : 'text-txt-tertiary'}`}>
{remaining}
</span>
)}
{/* GIF button */}
{gifEnabled && (
<button
onClick={() => togglePopover('gif')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
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>
)}
{/* Emoji button */}
<button
onClick={() => togglePopover('emoji')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
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>
{/* Send button — appears when there's content to send */}
{canSend && (
<button
onClick={handleSubmit}
disabled={isUploading}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message"
title="Send"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
</svg>
</button>
)}
</div>
</div>
</div>
);
}