feat: redesign private messaging inbox

This commit is contained in:
2026-03-17 18:34:00 +01:00
parent 2119741ba7
commit 7b37259a2c
5 changed files with 1278 additions and 985 deletions

View File

@@ -3,32 +3,38 @@ 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 }) {
export default function MessageBubble({ message, isMine, showAvatar, endsSequence = true, isNewlyArrived = false, prefersReducedMotion = false, onReact, onUnreact, onEdit, onDelete = null, onReport = null, onOpenImage = null, seenText = null }) {
const [showPicker, setShowPicker] = useState(false)
const [editing, setEditing] = useState(false)
const [editBody, setEditBody] = useState(message.body ?? '')
const [showActions, setShowActions] = useState(false)
const [editing, setEditing] = useState(false)
const [editBody, setEditBody] = useState(message.body ?? '')
const [savingEdit, setSavingEdit] = useState(false)
const editRef = useRef(null)
const [isArrivalVisible, setIsArrivalVisible] = useState(true)
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)
const isEdited = !!message.edited_at
const username = message.sender?.username ?? 'Unknown'
const time = formatTime(message.created_at)
const initials = username.charAt(0).toUpperCase()
// Focus textarea when entering edit mode
useEffect(() => {
if (editing) {
editRef.current?.focus()
editRef.current?.setSelectionRange(editBody.length, editBody.length)
}
}, [editing])
}, [editing, editBody.length])
useEffect(() => {
if (prefersReducedMotion || !isNewlyArrived) {
setIsArrivalVisible(true)
return undefined
}
setIsArrivalVisible(false)
const frame = window.requestAnimationFrame(() => setIsArrivalVisible(true))
return () => window.cancelAnimationFrame(frame)
}, [isNewlyArrived, prefersReducedMotion])
const reactionGroups = groupReactions(message.reactions ?? [])
@@ -45,50 +51,77 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
}
const handleEditKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
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'}`}
className={`group flex items-end gap-2.5 sm:gap-3 ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-4' : 'mt-1'}`}
style={prefersReducedMotion ? undefined : {
opacity: isArrivalVisible ? 1 : 0.55,
transform: isArrivalVisible ? 'translateY(0px)' : 'translateY(10px)',
filter: isNewlyArrived && isArrivalVisible ? 'drop-shadow(0 0 20px rgba(56,189,248,0.12))' : 'none',
transition: 'opacity 260ms ease, transform 320ms ease, filter 900ms ease',
}}
onMouseEnter={() => !isDeleted && !editing && setShowPicker(true)}
onMouseLeave={() => setShowPicker(false)}
onMouseLeave={() => {
setShowPicker(false)
setShowActions(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 className={`h-9 w-9 shrink-0 ${showAvatar ? 'visible' : 'invisible'}`}>
<div className={`flex h-9 w-9 items-center justify-center rounded-2xl text-xs font-semibold text-white shadow-[0_10px_20px_rgba(0,0,0,0.18)] ${isMine ? 'bg-gradient-to-br from-sky-500 to-cyan-500' : 'bg-gradient-to-br from-violet-500 to-fuchsia-600'}`}>
{initials}
</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 className={`flex max-w-[84%] flex-col sm:max-w-[75%] ${isMine ? 'items-end' : 'items-start'}`}>
{showAvatar ? (
<div className={`mb-1.5 flex items-center gap-2 ${isMine ? 'flex-row-reverse' : ''}`}>
<span className="text-xs font-medium text-white/78">{username}</span>
<span className="text-[11px] text-white/30">{time}</span>
</div>
)}
) : null}
{/* Bubble */}
<div className="relative">
{!editing && !isDeleted ? (
<button
type="button"
onClick={() => setShowActions((prev) => !prev)}
className={`absolute z-[1] inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/[0.08] bg-[#0b1220]/88 text-white/40 shadow-[0_10px_20px_rgba(0,0,0,0.18)] transition hover:bg-[#11192a] hover:text-white ${isMine ? '-left-3 top-2 md:opacity-0 md:group-hover:opacity-100' : '-right-3 top-2 md:opacity-0 md:group-hover:opacity-100'}`}
aria-label="Message actions"
>
<i className="fa-solid fa-ellipsis text-[11px]" />
</button>
) : null}
{editing ? (
<div className="w-72">
<textarea
ref={editRef}
value={editBody}
onChange={e => setEditBody(e.target.value)}
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"
className="w-full resize-none rounded-2xl border border-sky-400/30 bg-black/20 px-3 py-2 text-sm text-white outline-none transition focus:border-sky-300/40"
/>
<div className="flex gap-2 mt-1 justify-end">
<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => { setEditing(false); setEditBody(message.body) }}
className="text-xs text-gray-400 hover:text-gray-600"
onClick={() => {
setEditing(false)
setEditBody(message.body)
}}
className="text-xs text-white/35 transition hover:text-white/65"
>
Cancel
</button>
@@ -96,7 +129,7 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
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"
className="text-xs font-medium text-sky-300 transition hover:text-sky-200 disabled:opacity-40"
>
{savingEdit ? 'Saving…' : 'Save'}
</button>
@@ -104,137 +137,137 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
</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' : ''}`}
className={`px-4 py-3 text-sm leading-relaxed shadow-[0_14px_35px_rgba(0,0,0,0.16)] ${bubbleShape({ isMine, showAvatar, endsSequence })} ${isMine ? 'border border-sky-400/24 bg-sky-500/16 text-white' : 'border border-white/[0.06] bg-white/[0.04] text-white/88'} ${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'}`}>
{message.attachments?.length > 0 ? (
<AttachmentCluster attachments={message.attachments} isMine={isMine} onOpenImage={onOpenImage} />
) : null}
<div className="prose prose-invert prose-sm max-w-none prose-headings:text-white prose-li:text-inherit prose-p:my-0 prose-pre:my-1 prose-strong:text-white text-white/90">
<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'}>
<a
href={href}
target="_blank"
rel="noopener noreferrer nofollow"
className={isMine ? 'text-sky-100 underline' : 'text-sky-300 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>,
: <code className={`rounded px-1 py-0.5 text-xs ${isMine ? 'bg-sky-500/20' : 'bg-white/[0.08]'}`}>{children}</code>,
}}
>
{message.body}
</ReactMarkdown>
</div>
{isEdited && (
<span className={`text-xs ml-1 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>(edited)</span>
)}
{isEdited ? (
<span className={`ml-1 text-xs ${isMine ? 'text-sky-100/80' : 'text-white/40'}`}>(edited)</span>
) : null}
</>
)}
</div>
)}
{/* Hover action strip: reactions + edit pencil */}
{showPicker && !editing && !isDeleted && (
{(showPicker || showActions) && !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`}
className={`absolute bottom-full z-10 mb-2 flex items-center gap-1 whitespace-nowrap rounded-full border border-white/[0.08] bg-[#0b1220] px-2 py-1 shadow-[0_20px_40px_rgba(0,0,0,0.35)] ${isMine ? 'right-0' : 'left-0'}`}
onMouseEnter={() => setShowPicker(true)}
onMouseLeave={() => setShowPicker(false)}
onMouseLeave={() => {
setShowPicker(false)
setShowActions(false)
}}
>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(message.id, emoji)}
className="text-base hover:scale-125 transition-transform" title={`React ${emoji}`}>
{QUICK_REACTIONS.map((emoji) => (
<button key={emoji} onClick={() => {
onReact(message.id, emoji)
setShowActions(false)
}} className="inline-flex h-8 w-8 items-center justify-center rounded-full text-base transition-transform hover:scale-125" title={`React ${emoji}`}>
{emoji}
</button>
))}
{isMine && (
{isMine ? (
<>
<button
type="button"
onClick={() => {
setEditing(true)
setShowPicker(false)
setShowActions(false)
}}
className="ml-1 inline-flex h-8 w-8 items-center justify-center rounded-full text-white/35 transition hover:bg-white/[0.06] hover:text-white/80"
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>
{onDelete ? (
<button
type="button"
onClick={() => {
onDelete(message.id)
setShowPicker(false)
setShowActions(false)
}}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-white/35 transition hover:bg-rose-500/10 hover:text-rose-300"
title="Delete message"
>
<i className="fa-solid fa-trash text-[11px]" />
</button>
) : null}
</>
) : null}
{!isMine && onReport ? (
<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"
type="button"
onClick={() => {
onReport(message.id)
setShowPicker(false)
setShowActions(false)
}}
className="ml-1 inline-flex h-8 w-8 items-center justify-center rounded-full text-white/35 transition hover:bg-rose-500/10 hover:text-rose-300"
title="Report message"
>
</button>
)}
) : null}
</div>
)}
) : null}
</div>
{/* Reactions bar */}
{reactionGroups.length > 0 && !isDeleted && (
<div className={`flex flex-wrap gap-1 mt-1 ${isMine ? 'justify-end' : 'justify-start'}`}>
{reactionGroups.length > 0 && !isDeleted ? (
<div className={`mt-1 flex flex-wrap gap-1 ${isMine ? 'justify-end' : 'justify-start'} ${endsSequence ? '' : 'mb-0.5'}`}>
{reactionGroups.map(({ emoji, count, iReacted }) => (
<button
key={emoji}
onClick={() => iReacted ? onUnreact(message.id, emoji) : onReact(message.id, 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'
}`}
className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs transition-colors ${iReacted ? 'border border-sky-400/20 bg-sky-500/14 text-sky-100' : 'border border-white/[0.08] bg-white/[0.04] text-white/55 hover:bg-white/[0.08] hover:text-white'}`}
>
<span>{emoji}</span>
<span>{count}</span>
</button>
))}
</div>
)}
) : null}
{isMine && seenText && (
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
{isMine && seenText ? (
<div className="mt-1 text-[11px] text-white/30">
{seenText}
</div>
)}
) : null}
</div>
</div>
)
@@ -244,13 +277,102 @@ function formatTime(iso) {
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
function bubbleShape({ isMine, showAvatar, endsSequence }) {
if (isMine) {
return `${showAvatar ? 'rounded-t-[24px]' : 'rounded-tl-[24px] rounded-tr-[14px]'} ${endsSequence ? 'rounded-bl-[24px] rounded-br-[8px]' : 'rounded-bl-[24px] rounded-br-[14px]'}`
}
return `${showAvatar ? 'rounded-t-[24px]' : 'rounded-tr-[24px] rounded-tl-[14px]'} ${endsSequence ? 'rounded-br-[24px] rounded-bl-[8px]' : 'rounded-br-[24px] rounded-bl-[14px]'}`
}
function AttachmentCluster({ attachments, isMine, onOpenImage }) {
const images = attachments.filter((attachment) => attachment.type === 'image')
const files = attachments.filter((attachment) => attachment.type !== 'image')
return (
<div className="mb-3 space-y-2.5">
{images.length > 0 ? <AttachmentImageGrid images={images} onOpenImage={onOpenImage} /> : null}
{files.length > 0 ? <AttachmentFileStack files={files} isMine={isMine} /> : null}
</div>
)
}
function AttachmentImageGrid({ images, onOpenImage }) {
const gridClass = images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'
return (
<div className={`grid gap-2 ${gridClass}`}>
{images.slice(0, 4).map((attachment, index) => {
const remaining = images.length - 4
const showOverlay = remaining > 0 && index === 3
return (
<button
key={attachment.id}
type="button"
onClick={() => onOpenImage?.({
id: attachment.id,
original_name: attachment.original_name,
url: `/messages/attachments/${attachment.id}`,
})}
className={`group/image relative overflow-hidden rounded-[20px] border border-white/15 bg-black/20 ${images.length === 1 ? 'max-w-md' : ''}`}
>
<img
src={`/messages/attachments/${attachment.id}`}
alt={attachment.original_name}
className={`w-full object-cover transition duration-200 group-hover/image:scale-[1.02] ${images.length === 1 ? 'max-h-72' : 'h-32 sm:h-36'}`}
loading="lazy"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-3 py-2 text-left text-xs text-white/80">
<span className="line-clamp-1">{attachment.original_name}</span>
</div>
{showOverlay ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-lg font-semibold text-white">
+{remaining}
</div>
) : null}
</button>
)
})}
</div>
)
}
function AttachmentFileStack({ files, isMine }) {
return (
<div className={`rounded-[20px] border px-3 py-3 ${isMine ? 'border-sky-400/18 bg-sky-500/10' : 'border-white/[0.08] bg-black/20'}`}>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Files</span>
<span className="text-[11px] text-white/35">{files.length}</span>
</div>
<div className="space-y-2">
{files.map((attachment) => (
<a
key={attachment.id}
href={`/messages/attachments/${attachment.id}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 rounded-2xl border px-3 py-2 text-left text-xs transition hover:translate-x-[1px] ${isMine ? 'border-sky-400/12 bg-sky-500/10 text-sky-50 hover:bg-sky-500/14' : 'border-white/[0.08] bg-white/[0.04] text-white/80 hover:bg-white/[0.06]'}`}
>
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${isMine ? 'bg-sky-500/16 text-sky-100' : 'bg-white/[0.08] text-white/70'}`}>
<i className="fa-solid fa-paperclip text-[11px]" />
</span>
<span className="min-w-0 flex-1 truncate">{attachment.original_name}</span>
<i className="fa-solid fa-arrow-up-right-from-square text-[10px] opacity-60" />
</a>
))}
</div>
</div>
)
}
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
for (const reaction of reactions) {
if (!map.has(reaction.reaction)) map.set(reaction.reaction, { count: 0, iReacted: false })
const entry = map.get(reaction.reaction)
entry.count += 1
if (reaction._iMine) entry.iReacted = true
}
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
}