248 lines
9.4 KiB
JavaScript
248 lines
9.4 KiB
JavaScript
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { api } from '../utils/api.js';
|
|
import './MessageInput.css';
|
|
|
|
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
|
|
|
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
|
const [text, setText] = useState('');
|
|
const [imageFile, setImageFile] = useState(null);
|
|
const [imagePreview, setImagePreview] = useState(null);
|
|
const [mentionSearch, setMentionSearch] = useState('');
|
|
const [mentionResults, setMentionResults] = useState([]);
|
|
const [mentionIndex, setMentionIndex] = useState(-1);
|
|
const [showMention, setShowMention] = useState(false);
|
|
const [linkPreview, setLinkPreview] = useState(null);
|
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
|
const inputRef = useRef(null);
|
|
const typingTimer = useRef(null);
|
|
const wasTyping = useRef(false);
|
|
const mentionStart = useRef(-1);
|
|
const fileInput = useRef(null);
|
|
|
|
// Handle typing notification
|
|
const handleTypingChange = (value) => {
|
|
if (value && !wasTyping.current) {
|
|
wasTyping.current = true;
|
|
onTyping(true);
|
|
}
|
|
if (typingTimer.current) clearTimeout(typingTimer.current);
|
|
typingTimer.current = setTimeout(() => {
|
|
if (wasTyping.current) {
|
|
wasTyping.current = false;
|
|
onTyping(false);
|
|
}
|
|
}, 2000);
|
|
};
|
|
|
|
// Link preview
|
|
const fetchPreview = useCallback(async (url) => {
|
|
setLoadingPreview(true);
|
|
try {
|
|
const { preview } = await api.getLinkPreview(url);
|
|
if (preview) setLinkPreview(preview);
|
|
} catch {}
|
|
setLoadingPreview(false);
|
|
}, []);
|
|
|
|
const handleChange = (e) => {
|
|
const val = e.target.value;
|
|
setText(val);
|
|
handleTypingChange(val);
|
|
|
|
// Detect @mention
|
|
const cur = e.target.selectionStart;
|
|
const lastAt = val.lastIndexOf('@', cur - 1);
|
|
if (lastAt !== -1) {
|
|
const between = val.slice(lastAt + 1, cur);
|
|
if (!between.includes(' ') && !between.includes('\n')) {
|
|
mentionStart.current = lastAt;
|
|
setMentionSearch(between);
|
|
setShowMention(true);
|
|
api.searchUsers(between).then(({ users }) => {
|
|
setMentionResults(users);
|
|
setMentionIndex(0);
|
|
}).catch(() => {});
|
|
return;
|
|
}
|
|
}
|
|
setShowMention(false);
|
|
|
|
// Link preview
|
|
const urls = val.match(URL_REGEX);
|
|
if (urls && urls[0] !== linkPreview?.url) {
|
|
fetchPreview(urls[0]);
|
|
} else if (!urls) {
|
|
setLinkPreview(null);
|
|
}
|
|
};
|
|
|
|
const insertMention = (user) => {
|
|
const before = text.slice(0, mentionStart.current);
|
|
const after = text.slice(inputRef.current.selectionStart);
|
|
const mention = `@[${user.display_name || user.name}](${user.id}) `;
|
|
setText(before + mention + after);
|
|
setShowMention(false);
|
|
setMentionResults([]);
|
|
inputRef.current.focus();
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (showMention && mentionResults.length > 0) {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1)); return; }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; }
|
|
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (mentionIndex >= 0) insertMention(mentionResults[mentionIndex]); return; }
|
|
if (e.key === 'Escape') { setShowMention(false); return; }
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed && !imageFile) return;
|
|
|
|
const lp = linkPreview;
|
|
setText('');
|
|
setLinkPreview(null);
|
|
setImageFile(null);
|
|
setImagePreview(null);
|
|
wasTyping.current = false;
|
|
onTyping(false);
|
|
|
|
await onSend({ content: trimmed || null, imageFile, linkPreview: lp });
|
|
};
|
|
|
|
const compressImage = (file) => new Promise((resolve) => {
|
|
const MAX_PX = 1920;
|
|
const QUALITY = 0.82;
|
|
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
|
|
} else {
|
|
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
|
width = Math.round(width * ratio);
|
|
height = Math.round(height * ratio);
|
|
}
|
|
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);
|
|
};
|
|
img.src = url;
|
|
});
|
|
|
|
const handleImageSelect = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const compressed = await compressImage(file);
|
|
setImageFile(compressed);
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => setImagePreview(e.target.result);
|
|
reader.readAsDataURL(compressed);
|
|
};
|
|
|
|
const displayText = (t) => {
|
|
// Convert @[name](id) to @name for display
|
|
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
|
|
};
|
|
|
|
return (
|
|
<div className="message-input-area">
|
|
{/* Reply preview */}
|
|
{replyTo && (
|
|
<div className="reply-bar-input">
|
|
<div className="reply-indicator">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
|
<span>Replying to <strong>{replyTo.user_display_name || replyTo.user_name}</strong></span>
|
|
<span className="reply-preview-text">{replyTo.content?.slice(0, 60) || (replyTo.image_url ? '📷 Image' : '')}</span>
|
|
</div>
|
|
<button className="btn-icon" onClick={onCancelReply}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image preview */}
|
|
{imagePreview && (
|
|
<div className="img-preview-bar">
|
|
<img src={imagePreview} alt="preview" className="img-preview" />
|
|
<button className="btn-icon" onClick={() => { setImageFile(null); setImagePreview(null); }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Link preview */}
|
|
{linkPreview && (
|
|
<div className="link-preview-bar">
|
|
{linkPreview.image && <img src={linkPreview.image} alt="" className="link-prev-img" onError={e => e.target.style.display='none'} />}
|
|
<div className="flex-col flex-1 overflow-hidden gap-1">
|
|
{linkPreview.siteName && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', textTransform: 'uppercase' }}>{linkPreview.siteName}</span>}
|
|
<span style={{ fontSize: 13, fontWeight: 600 }} className="truncate">{linkPreview.title}</span>
|
|
</div>
|
|
<button className="btn-icon" onClick={() => setLinkPreview(null)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mention dropdown */}
|
|
{showMention && mentionResults.length > 0 && (
|
|
<div className="mention-dropdown">
|
|
{mentionResults.map((u, i) => (
|
|
<button
|
|
key={u.id}
|
|
className={`mention-item ${i === mentionIndex ? 'active' : ''}`}
|
|
onMouseDown={(e) => { e.preventDefault(); insertMention(u); }}
|
|
>
|
|
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
|
|
<span>{u.display_name || u.name}</span>
|
|
<span className="mention-role">{u.role}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
|
|
|
<div className="input-wrap">
|
|
<textarea
|
|
ref={inputRef}
|
|
className="msg-input"
|
|
placeholder={`Message ${group?.name || ''}...`}
|
|
value={text}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
rows={1}
|
|
style={{ resize: 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`}
|
|
onClick={handleSend}
|
|
disabled={!text.trim() && !imageFile}
|
|
title="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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|