v0.3.0
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import './MessageInput.css';
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||
|
||||
// Detect if a string is purely emoji characters (no other text)
|
||||
function isEmojiOnly(str) {
|
||||
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
|
||||
return emojiRegex.test(str.trim());
|
||||
}
|
||||
|
||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
||||
const [text, setText] = useState('');
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
@@ -14,11 +22,30 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const [showMention, setShowMention] = useState(false);
|
||||
const [linkPreview, setLinkPreview] = useState(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const typingTimer = useRef(null);
|
||||
const wasTyping = useRef(false);
|
||||
const mentionStart = useRef(-1);
|
||||
const fileInput = useRef(null);
|
||||
const cameraInput = useRef(null);
|
||||
const attachMenuRef = useRef(null);
|
||||
const emojiPickerRef = useRef(null);
|
||||
|
||||
// Close attach menu / emoji picker on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) {
|
||||
setShowAttachMenu(false);
|
||||
}
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// Handle typing notification
|
||||
const handleTypingChange = (value) => {
|
||||
@@ -35,13 +62,26 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Link preview
|
||||
// Link preview — 5 second timeout, then abandon and enable Send
|
||||
const previewTimeoutRef = useRef(null);
|
||||
|
||||
const fetchPreview = useCallback(async (url) => {
|
||||
setLoadingPreview(true);
|
||||
setLinkPreview(null);
|
||||
|
||||
if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current);
|
||||
const abandonTimer = setTimeout(() => {
|
||||
setLoadingPreview(false);
|
||||
}, 5000);
|
||||
previewTimeoutRef.current = abandonTimer;
|
||||
|
||||
try {
|
||||
const { preview } = await api.getLinkPreview(url);
|
||||
clearTimeout(abandonTimer);
|
||||
if (preview) setLinkPreview(preview);
|
||||
} catch {}
|
||||
} catch {
|
||||
clearTimeout(abandonTimer);
|
||||
}
|
||||
setLoadingPreview(false);
|
||||
}, []);
|
||||
|
||||
@@ -50,7 +90,13 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
setText(val);
|
||||
handleTypingChange(val);
|
||||
|
||||
// Detect @mention
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
|
||||
const maxHeight = lineHeight * 5 + 20;
|
||||
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
|
||||
const cur = e.target.selectionStart;
|
||||
const lastAt = val.lastIndexOf('@', cur - 1);
|
||||
if (lastAt !== -1) {
|
||||
@@ -68,7 +114,6 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
}
|
||||
setShowMention(false);
|
||||
|
||||
// Link preview
|
||||
const urls = val.match(URL_REGEX);
|
||||
if (urls && urls[0] !== linkPreview?.url) {
|
||||
fetchPreview(urls[0]);
|
||||
@@ -112,20 +157,33 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
setImagePreview(null);
|
||||
wasTyping.current = false;
|
||||
onTyping(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
inputRef.current.style.overflowY = 'hidden';
|
||||
}
|
||||
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp });
|
||||
// Tag emoji-only messages so they can be rendered large
|
||||
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
|
||||
};
|
||||
|
||||
// Send a single emoji directly (from picker)
|
||||
const handleEmojiSend = async (emoji) => {
|
||||
setShowEmojiPicker(false);
|
||||
await onSend({ content: emoji.native, imageFile: null, linkPreview: null, emojiOnly: true });
|
||||
};
|
||||
|
||||
const compressImage = (file) => new Promise((resolve) => {
|
||||
const MAX_PX = 1920;
|
||||
const QUALITY = 0.82;
|
||||
const isPng = file.type === 'image/png';
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
let { width, height } = img;
|
||||
if (width <= MAX_PX && height <= MAX_PX) {
|
||||
// Already small enough — still re-encode to strip EXIF and reduce size
|
||||
// already small
|
||||
} else {
|
||||
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
||||
width = Math.round(width * ratio);
|
||||
@@ -134,10 +192,17 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(blob => {
|
||||
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
||||
}, 'image/jpeg', QUALITY);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!isPng) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
if (isPng) {
|
||||
canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/png' })), 'image/png');
|
||||
} else {
|
||||
canvas.toBlob(blob => resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })), 'image/jpeg', QUALITY);
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
@@ -150,12 +215,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setImagePreview(e.target.result);
|
||||
reader.readAsDataURL(compressed);
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const displayText = (t) => {
|
||||
// Convert @[name](id) to @name for display
|
||||
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
|
||||
};
|
||||
// Detect mobile (touch device)
|
||||
const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
return (
|
||||
<div className="message-input-area">
|
||||
@@ -215,10 +279,59 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
)}
|
||||
|
||||
<div className="input-row">
|
||||
<button className="btn-icon input-action" onClick={() => fileInput.current?.click()} title="Attach image">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
|
||||
{/* + button — attach menu trigger */}
|
||||
<div className="attach-wrap" ref={attachMenuRef}>
|
||||
<button
|
||||
className="btn-icon input-action attach-btn"
|
||||
onClick={() => { setShowAttachMenu(v => !v); setShowEmojiPicker(false); }}
|
||||
title="Add photo or emoji"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showAttachMenu && (
|
||||
<div className="attach-menu">
|
||||
{/* Photo from library */}
|
||||
<button className="attach-item" onClick={() => fileInput.current?.click()}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
<span>Photo</span>
|
||||
</button>
|
||||
{/* Camera — mobile only */}
|
||||
{isMobile() && (
|
||||
<button className="attach-item" onClick={() => cameraInput.current?.click()}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
<span>Camera</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Emoji */}
|
||||
<button className="attach-item" onClick={() => { setShowAttachMenu(false); setShowEmojiPicker(true); }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 13s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
<span>Emoji</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||
<input ref={cameraInput} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||
|
||||
{/* Emoji picker popover */}
|
||||
{showEmojiPicker && (
|
||||
<div className="emoji-input-picker" ref={emojiPickerRef}>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiSend}
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
maxFrequentRows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-wrap">
|
||||
<textarea
|
||||
@@ -234,12 +347,15 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`}
|
||||
className={`send-btn ${(text.trim() || imageFile) && !loadingPreview ? 'active' : ''}`}
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim() && !imageFile}
|
||||
title="Send"
|
||||
disabled={(!text.trim() && !imageFile) || loadingPreview}
|
||||
title={loadingPreview ? 'Loading preview…' : 'Send'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
{loadingPreview
|
||||
? <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user