Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
import React, { useState, useRef, useEffect } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
|
|
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
|
|
|
/**
|
|
* Individual message bubble with:
|
|
* - Markdown rendering (no raw HTML allowed)
|
|
* - Hover reaction picker + unreact on click
|
|
* - Inline edit for own messages
|
|
* - Soft-delete display
|
|
*/
|
|
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, onOpenImage = null, seenText = null }) {
|
|
const [showPicker, setShowPicker] = useState(false)
|
|
const [editing, setEditing] = useState(false)
|
|
const [editBody, setEditBody] = useState(message.body ?? '')
|
|
const [savingEdit, setSavingEdit] = useState(false)
|
|
const editRef = useRef(null)
|
|
|
|
const isDeleted = !!message.deleted_at
|
|
const isEdited = !!message.edited_at
|
|
const username = message.sender?.username ?? 'Unknown'
|
|
const time = formatTime(message.created_at)
|
|
|
|
// Focus textarea when entering edit mode
|
|
useEffect(() => {
|
|
if (editing) {
|
|
editRef.current?.focus()
|
|
editRef.current?.setSelectionRange(editBody.length, editBody.length)
|
|
}
|
|
}, [editing])
|
|
|
|
const reactionGroups = groupReactions(message.reactions ?? [])
|
|
|
|
const handleSaveEdit = async () => {
|
|
const trimmed = editBody.trim()
|
|
if (!trimmed || trimmed === message.body || savingEdit) return
|
|
setSavingEdit(true)
|
|
try {
|
|
await onEdit(message.id, trimmed)
|
|
setEditing(false)
|
|
} finally {
|
|
setSavingEdit(false)
|
|
}
|
|
}
|
|
|
|
const handleEditKeyDown = (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
|
|
if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`group flex gap-2 items-end ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-3' : 'mt-0.5'}`}
|
|
onMouseEnter={() => !isDeleted && !editing && setShowPicker(true)}
|
|
onMouseLeave={() => setShowPicker(false)}
|
|
>
|
|
{/* Avatar */}
|
|
<div className={`flex-shrink-0 w-7 h-7 ${showAvatar ? 'visible' : 'invisible'}`}>
|
|
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-400 to-pink-400 flex items-center justify-center text-white text-xs font-medium select-none">
|
|
{username.charAt(0).toUpperCase()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`max-w-[75%] flex flex-col ${isMine ? 'items-end' : 'items-start'}`}>
|
|
{/* Sender name & time */}
|
|
{showAvatar && (
|
|
<div className={`flex items-center gap-1.5 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
|
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{username}</span>
|
|
<span className="text-xs text-gray-400">{time}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bubble */}
|
|
<div className="relative">
|
|
{editing ? (
|
|
<div className="w-72">
|
|
<textarea
|
|
ref={editRef}
|
|
value={editBody}
|
|
onChange={e => setEditBody(e.target.value)}
|
|
onKeyDown={handleEditKeyDown}
|
|
rows={3}
|
|
maxLength={5000}
|
|
className="w-full resize-none rounded-xl border border-blue-400 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
<div className="flex gap-2 mt-1 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setEditing(false); setEditBody(message.body) }}
|
|
className="text-xs text-gray-400 hover:text-gray-600"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveEdit}
|
|
disabled={savingEdit || !editBody.trim() || editBody.trim() === message.body}
|
|
className="text-xs text-blue-500 hover:text-blue-700 font-medium disabled:opacity-40"
|
|
>
|
|
{savingEdit ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`px-3 py-2 rounded-2xl text-sm leading-relaxed break-words ${
|
|
isMine
|
|
? 'bg-blue-500 text-white rounded-br-sm'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-sm'
|
|
} ${isDeleted ? 'italic opacity-60' : ''} ${message._optimistic ? 'opacity-70' : ''}`}
|
|
>
|
|
{isDeleted ? (
|
|
<span>This message was deleted.</span>
|
|
) : (
|
|
<>
|
|
{message.attachments?.length > 0 && (
|
|
<div className="mb-2 space-y-1">
|
|
{message.attachments.map(att => (
|
|
<div key={att.id}>
|
|
{att.type === 'image' ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenImage?.({ id: att.id, original_name: att.original_name, url: `/messages/attachments/${att.id}` })}
|
|
className="block"
|
|
>
|
|
<img
|
|
src={`/messages/attachments/${att.id}`}
|
|
alt={att.original_name}
|
|
className="max-h-44 rounded-lg border border-white/20"
|
|
loading="lazy"
|
|
/>
|
|
</button>
|
|
) : (
|
|
<a
|
|
href={`/messages/attachments/${att.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${isMine ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100'}`}
|
|
>
|
|
📎 {att.original_name}
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className={`prose prose-sm max-w-none prose-p:my-0 prose-pre:my-1 ${isMine ? 'prose-invert' : 'dark:prose-invert'}`}>
|
|
<ReactMarkdown
|
|
allowedElements={['p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'br']}
|
|
unwrapDisallowed
|
|
components={{
|
|
a: ({ href, children }) => (
|
|
<a href={href} target="_blank" rel="noopener noreferrer nofollow"
|
|
className={isMine ? 'text-blue-200 underline' : 'text-blue-600 dark:text-blue-400 underline'}>
|
|
{children}
|
|
</a>
|
|
),
|
|
code: ({ children, className }) => className
|
|
? <code className={`${className} text-xs`}>{children}</code>
|
|
: <code className={`px-1 py-0.5 rounded text-xs ${isMine ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>{children}</code>,
|
|
}}
|
|
>
|
|
{message.body}
|
|
</ReactMarkdown>
|
|
</div>
|
|
{isEdited && (
|
|
<span className={`text-xs ml-1 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>(edited)</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Hover action strip: reactions + edit pencil */}
|
|
{showPicker && !editing && !isDeleted && (
|
|
<div
|
|
className={`absolute bottom-full mb-1 ${isMine ? 'right-0' : 'left-0'} flex items-center gap-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-full px-2 py-1 shadow-md z-10 whitespace-nowrap`}
|
|
onMouseEnter={() => setShowPicker(true)}
|
|
onMouseLeave={() => setShowPicker(false)}
|
|
>
|
|
{QUICK_REACTIONS.map(emoji => (
|
|
<button key={emoji} onClick={() => onReact(message.id, emoji)}
|
|
className="text-base hover:scale-125 transition-transform" title={`React ${emoji}`}>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
{isMine && (
|
|
<button
|
|
onClick={() => { setEditing(true); setShowPicker(false) }}
|
|
className="ml-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
|
title="Edit message"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{!isMine && onReport && (
|
|
<button
|
|
onClick={() => { onReport(message.id); setShowPicker(false) }}
|
|
className="ml-1 text-gray-400 hover:text-red-500"
|
|
title="Report message"
|
|
>
|
|
⚑
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reactions bar */}
|
|
{reactionGroups.length > 0 && !isDeleted && (
|
|
<div className={`flex flex-wrap gap-1 mt-1 ${isMine ? 'justify-end' : 'justify-start'}`}>
|
|
{reactionGroups.map(({ emoji, count, iReacted }) => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => iReacted ? onUnreact(message.id, emoji) : onReact(message.id, emoji)}
|
|
title={iReacted ? 'Remove reaction' : `React ${emoji}`}
|
|
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors ${
|
|
iReacted
|
|
? 'bg-blue-100 dark:bg-blue-900/40 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
|
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
<span>{emoji}</span>
|
|
<span>{count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isMine && seenText && (
|
|
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
|
{seenText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatTime(iso) {
|
|
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
function groupReactions(reactions) {
|
|
const map = new Map()
|
|
for (const r of reactions) {
|
|
if (!map.has(r.reaction)) map.set(r.reaction, { count: 0, iReacted: false })
|
|
const entry = map.get(r.reaction)
|
|
entry.count++
|
|
if (r._iMine) entry.iReacted = true
|
|
}
|
|
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
|
|
}
|