Save workspace changes
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ConversationList({ conversations, loading, activeId, currentUserId, onlineUserIds = [], typingByConversation = {}, onSelect }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Conversations</p>
|
||||
<p className="mt-1 text-xs text-white/35">Recent threads and group rooms</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/45">
|
||||
{conversations.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="nova-scrollbar-message flex-1 space-y-2 overflow-y-auto p-3 pr-2">
|
||||
{loading ? (
|
||||
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations…</li>
|
||||
) : null}
|
||||
|
||||
{!loading && conversations.length === 0 ? (
|
||||
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">No conversations yet.</li>
|
||||
) : null}
|
||||
|
||||
{conversations.map((conversation) => (
|
||||
<ConversationRow
|
||||
key={conversation.id}
|
||||
conv={conversation}
|
||||
isActive={conversation.id === activeId}
|
||||
currentUserId={currentUserId}
|
||||
onlineUserIds={onlineUserIds}
|
||||
typingUsers={typingByConversation[conversation.id] ?? []}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingUsers, onClick }) {
|
||||
const label = convLabel(conv, currentUserId)
|
||||
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
|
||||
const preview = typingUsers.length > 0
|
||||
? buildTypingPreview(typingUsers)
|
||||
: lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
|
||||
const unread = conv.unread_count ?? 0
|
||||
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
|
||||
const otherParticipant = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
|
||||
const isArchived = myParticipant?.is_archived ?? false
|
||||
const isPinned = myParticipant?.is_pinned ?? false
|
||||
const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0
|
||||
const onlineMembers = conv.type === 'group'
|
||||
? conv.all_participants?.filter((participant) => participant.user_id !== currentUserId && onlineUserIds.includes(Number(participant.user_id)) && !participant.left_at).length ?? 0
|
||||
: 0
|
||||
const isDirectOnline = conv.type === 'direct' && otherParticipant ? onlineUserIds.includes(Number(otherParticipant.user_id)) : false
|
||||
const typeLabel = conv.type === 'group'
|
||||
? (onlineMembers > 0 ? `${onlineMembers} online` : `${activeMembers} members`)
|
||||
: (isDirectOnline ? 'Online now' : 'Direct message')
|
||||
const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null
|
||||
const initials = label
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('') || 'M'
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
|
||||
{initials}
|
||||
</div>
|
||||
{isDirectOnline ? <span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-[#0a101a] bg-emerald-300 shadow-[0_0_0_6px_rgba(16,185,129,0.08)]" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate text-sm font-semibold ${isActive ? 'text-sky-100' : 'text-white/90'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{isPinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null}
|
||||
{isArchived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] uppercase tracking-[0.18em] text-white/30">{typeLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{conv.last_message_at ? <span className="text-[11px] text-white/30">{relativeTime(conv.last_message_at)}</span> : null}
|
||||
{unread > 0 ? (
|
||||
<span className="inline-flex min-w-[1.55rem] items-center justify-center rounded-full border border-sky-400/20 bg-sky-500/14 px-1.5 py-0.5 text-[11px] font-semibold text-sky-100">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
|
||||
{typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
|
||||
<p data-testid={`conversation-preview-${conv.id}`} className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
|
||||
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
|
||||
<span className="truncate">{preview}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function convLabel(conv, currentUserId) {
|
||||
if (conv.type === 'group') return conv.title ?? 'Group'
|
||||
const other = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
|
||||
return other?.user?.username ?? 'Direct message'
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return ''
|
||||
return str.length > max ? `${str.slice(0, max)}…` : str
|
||||
}
|
||||
|
||||
function buildTypingPreview(users) {
|
||||
const names = users.map((user) => `@${user.username}`)
|
||||
if (names.length === 1) return `${names[0]} is typing...`
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]} are typing...`
|
||||
return `${names[0]}, ${names[1]} and ${names.length - 2} others are typing...`
|
||||
}
|
||||
|
||||
function SidebarTypingIcon() {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_150ms_infinite]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_300ms_infinite]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function relativeTime(iso) {
|
||||
if (!iso) return 'No activity'
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
if (diff < 60) return 'Now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
||||
|
||||
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 [showActions, setShowActions] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editBody, setEditBody] = useState(message.body ?? '')
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
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 initials = username.charAt(0).toUpperCase()
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
editRef.current?.focus()
|
||||
editRef.current?.setSelectionRange(editBody.length, editBody.length)
|
||||
}
|
||||
}, [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 ?? [])
|
||||
|
||||
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
|
||||
id={message?.id ? `message-${message.id}` : undefined}
|
||||
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)
|
||||
setShowActions(false)
|
||||
}}
|
||||
>
|
||||
<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={`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}
|
||||
|
||||
<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)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
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="mt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(false)
|
||||
setEditBody(message.body)
|
||||
}}
|
||||
className="text-xs text-white/35 transition hover:text-white/65"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={savingEdit || !editBody.trim() || editBody.trim() === message.body}
|
||||
className="text-xs font-medium text-sky-300 transition hover:text-sky-200 disabled:opacity-40"
|
||||
>
|
||||
{savingEdit ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
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 ? (
|
||||
<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-sky-100 underline' : 'text-sky-300 underline'}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children, className }) => className
|
||||
? <code className={`${className} text-xs`}>{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={`ml-1 text-xs ${isMine ? 'text-sky-100/80' : 'text-white/40'}`}>(edited)</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showPicker || showActions) && !editing && !isDeleted ? (
|
||||
<div
|
||||
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)
|
||||
setShowActions(false)
|
||||
}}
|
||||
>
|
||||
{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 ? (
|
||||
<>
|
||||
<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
|
||||
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>
|
||||
|
||||
{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))}
|
||||
title={iReacted ? 'Remove reaction' : `React ${emoji}`}
|
||||
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-white/30">
|
||||
{seenText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 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 }))
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
export default function NewConversationModal({ currentUserId, apiFetch, onCreated, onClose }) {
|
||||
const [type, setType] = useState('direct')
|
||||
const [recipientInput, setRecipient] = useState('')
|
||||
const [groupTitle, setGroupTitle] = useState('')
|
||||
const [participantInputs, setParticipantInputs] = useState(['', ''])
|
||||
const [body, setBody] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const addParticipant = () => setParticipantInputs((prev) => [...prev, ''])
|
||||
const updateParticipant = (index, value) => setParticipantInputs((prev) => prev.map((entry, currentIndex) => (currentIndex === index ? value : entry)))
|
||||
const removeParticipant = (index) => setParticipantInputs((prev) => prev.filter((_, currentIndex) => currentIndex !== index))
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSending(true)
|
||||
|
||||
try {
|
||||
const payload = { type, body }
|
||||
|
||||
if (type === 'direct') {
|
||||
const user = await resolveUsername(recipientInput.trim(), apiFetch)
|
||||
payload.recipient_id = user.id
|
||||
} else {
|
||||
const resolved = await Promise.all(
|
||||
participantInputs
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => resolveUsername(entry, apiFetch)),
|
||||
)
|
||||
payload.participant_ids = resolved.map((user) => user.id)
|
||||
payload.title = groupTitle.trim()
|
||||
}
|
||||
|
||||
const conv = await apiFetch('/api/messages/conversation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
onCreated(conv)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [apiFetch, body, groupTitle, onCreated, participantInputs, recipientInput, type])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#020611cc] p-4 backdrop-blur-md">
|
||||
<div className="w-full max-w-xl overflow-hidden rounded-[32px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(12,18,29,0.98),rgba(7,11,18,0.96))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/35">Compose</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Start a new conversation</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/50">Send a direct message or set up a group thread with collaborators.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] text-white/55 transition hover:bg-white/[0.08] hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 px-6 py-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{['direct', 'group'].map((entryType) => (
|
||||
<button
|
||||
key={entryType}
|
||||
type="button"
|
||||
onClick={() => setType(entryType)}
|
||||
className={`rounded-[24px] border px-4 py-4 text-left transition ${type === entryType ? 'border-sky-400/28 bg-sky-500/[0.12] text-sky-100' : 'border-white/[0.08] bg-white/[0.03] text-white/65 hover:bg-white/[0.05] hover:text-white'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl ${entryType === 'direct' ? 'bg-sky-500/14 text-sky-200' : 'bg-fuchsia-500/14 text-fuchsia-200'}`}>
|
||||
<i className={`fa-solid ${entryType === 'direct' ? 'fa-user' : 'fa-user-group'}`} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{entryType === 'direct' ? 'Direct message' : 'Group conversation'}</p>
|
||||
<p className="mt-1 text-xs text-white/40">{entryType === 'direct' ? 'One creator, one thread.' : 'Coordinate with multiple people.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{type === 'direct' ? (
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Recipient username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientInput}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="username"
|
||||
required
|
||||
className="w-full rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Group name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupTitle}
|
||||
onChange={(e) => setGroupTitle(e.target.value)}
|
||||
placeholder="Group name"
|
||||
required
|
||||
maxLength={120}
|
||||
className="w-full rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Participants</label>
|
||||
<button type="button" onClick={addParticipant} className="text-xs font-medium text-sky-300 transition hover:text-sky-200">
|
||||
+ Add participant
|
||||
</button>
|
||||
</div>
|
||||
{participantInputs.map((value, index) => (
|
||||
<div key={index} className="mb-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateParticipant(index, e.target.value)}
|
||||
placeholder={`Username ${index + 1}`}
|
||||
required
|
||||
className="flex-1 rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||
/>
|
||||
{participantInputs.length > 2 ? (
|
||||
<button type="button" onClick={() => removeParticipant(index)} className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/45 transition hover:border-rose-400/18 hover:bg-rose-500/10 hover:text-rose-200">×</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Opening message</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write your message…"
|
||||
required
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
className="w-full resize-none rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||
/>
|
||||
<p className="mt-2 text-[11px] text-white/30">Keep it clear. You can continue the thread after the conversation is created.</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-rose-500/18 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.06] pt-5">
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/65 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="rounded-full bg-sky-500 px-5 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400 disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Creating…' : 'Create conversation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveUsername(username, apiFetch) {
|
||||
const data = await apiFetch(`/api/search/users?q=${encodeURIComponent(username)}&limit=1`)
|
||||
const user = data?.data?.[0] ?? data?.[0]
|
||||
if (!user) throw new Error(`User \"${username}\" not found.`)
|
||||
return user
|
||||
}
|
||||
Reference in New Issue
Block a user