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.
527 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|