import React, { useState, useRef, useCallback, useEffect, lazy, Suspense } from 'react' import axios from 'axios' import ShareArtworkModal from './ShareArtworkModal' import LinkPreviewCard from './LinkPreviewCard' import TagPeopleModal from './TagPeopleModal' // Lazy-load the heavy emoji picker only when first opened // @emoji-mart/react only has a default export (the Picker); m.Picker is undefined const EmojiPicker = lazy(() => import('@emoji-mart/react')) const VISIBILITY_OPTIONS = [ { value: 'public', icon: 'fa-globe', label: 'Public' }, { value: 'followers', icon: 'fa-user-friends', label: 'Followers' }, { value: 'private', icon: 'fa-lock', label: 'Private' }, ] const URL_RE = /https?:\/\/[^\s\])"'>]{4,}/gi function extractFirstUrl(text) { const m = text.match(URL_RE) return m ? m[0].replace(/[.,;:!?)]+$/, '') : null } /** * PostComposer * * Props: * user object { id, username, name, avatar } * onPosted function(newPost) */ export default function PostComposer({ user, onPosted }) { const [expanded, setExpanded] = useState(false) const [body, setBody] = useState('') const [visibility, setVisibility] = useState('public') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [shareModal, setShareModal] = useState(false) const [linkPreview, setLinkPreview] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const [previewDismissed, setPreviewDismissed] = useState(false) const [lastPreviewUrl, setLastPreviewUrl] = useState(null) const [emojiOpen, setEmojiOpen] = useState(false) const [emojiData, setEmojiData] = useState(null) // loaded lazily const [tagModal, setTagModal] = useState(false) const [taggedUsers, setTaggedUsers] = useState([]) // [{ id, username, name, avatar_url }] const [scheduleOpen, setScheduleOpen] = useState(false) const [scheduledAt, setScheduledAt] = useState('') // ISO datetime-local string const textareaRef = useRef(null) const debounceTimer = useRef(null) const emojiWrapRef = useRef(null) // wraps button + popover for outside-click // Load emoji-mart data lazily the first time the picker opens const openEmojiPicker = useCallback(async () => { if (!emojiData) { const { default: data } = await import('@emoji-mart/data') setEmojiData(data) } setEmojiOpen((v) => !v) }, [emojiData]) // Close picker on outside click useEffect(() => { if (!emojiOpen) return const handler = (e) => { if (emojiWrapRef.current && !emojiWrapRef.current.contains(e.target)) { setEmojiOpen(false) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [emojiOpen]) // Insert emoji at current cursor position const insertEmoji = useCallback((emoji) => { const native = emoji.native ?? emoji.shortcodes ?? '' const ta = textareaRef.current if (!ta) { setBody((b) => b + native) return } const start = ta.selectionStart ?? body.length const end = ta.selectionEnd ?? body.length const next = body.slice(0, start) + native + body.slice(end) setBody(next) // Restore cursor after the inserted emoji requestAnimationFrame(() => { ta.focus() const pos = start + native.length ta.setSelectionRange(pos, pos) }) setEmojiOpen(false) }, [body]) const handleFocus = () => { setExpanded(true) setTimeout(() => textareaRef.current?.focus(), 50) } const fetchLinkPreview = useCallback(async (url) => { setPreviewLoading(true) try { const { data } = await axios.get('/api/link-preview', { params: { url } }) if (data?.url) { setLinkPreview(data) } } catch { // silently ignore – preview is optional } finally { setPreviewLoading(false) } }, []) const handleBodyChange = (e) => { const val = e.target.value setBody(val) // Detect URLs and auto-fetch preview (debounced) clearTimeout(debounceTimer.current) debounceTimer.current = setTimeout(() => { const url = extractFirstUrl(val) if (!url || previewDismissed) return if (url === lastPreviewUrl) return setLastPreviewUrl(url) setLinkPreview(null) fetchLinkPreview(url) }, 700) } const handleDismissPreview = () => { setLinkPreview(null) setPreviewDismissed(true) } const resetComposer = () => { setBody('') setExpanded(false) setLinkPreview(null) setPreviewLoading(false) setPreviewDismissed(false) setLastPreviewUrl(null) setEmojiOpen(false) setTaggedUsers([]) setTagModal(false) setScheduleOpen(false) setScheduledAt('') } const handleSubmit = async (e) => { e?.preventDefault() if (!body.trim()) return setSubmitting(true) setError(null) try { const { data } = await axios.post('/api/posts', { type: 'text', visibility, body, link_preview: linkPreview ?? undefined, tagged_users: taggedUsers.length > 0 ? taggedUsers.map(({ id, username, name }) => ({ id, username, name })) : undefined, publish_at: scheduledAt || undefined, }) onPosted?.(data.post) resetComposer() } catch (err) { setError(err.response?.data?.message ?? 'Failed to post.') } finally { setSubmitting(false) } } const handleShared = (newPost) => { onPosted?.(newPost) setShareModal(false) } const showPreview = (linkPreview || previewLoading) && !previewDismissed return ( <>
{/* Collapsed: click-to-expand placeholder */} {!expanded ? (
e.key === 'Enter' && handleFocus()} aria-label="Create a post" > {user.name} Share an update with your followers.
) : (
{/* Textarea */}
{user.name}
{/* User identity byline */}
{user.name || `@${user.username}`} @{user.username}