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

@@ -1,22 +1,17 @@
import React, { useState, useCallback } from 'react'
/**
* Modal for creating a new direct or group conversation.
*/
export default function NewConversationModal({ currentUserId, apiFetch, onCreated, onClose }) {
const [type, setType] = useState('direct')
const [type, setType] = useState('direct')
const [recipientInput, setRecipient] = useState('')
const [groupTitle, setGroupTitle] = useState('')
const [groupTitle, setGroupTitle] = useState('')
const [participantInputs, setParticipantInputs] = useState(['', ''])
const [body, setBody] = useState('')
const [sending, setSending] = useState(false)
const [error, setError] = useState(null)
const [body, setBody] = useState('')
const [sending, setSending] = useState(false)
const [error, setError] = useState(null)
const addParticipant = () => setParticipantInputs(p => [...p, ''])
const updateParticipant = (i, val) =>
setParticipantInputs(p => p.map((v, idx) => idx === i ? val : v))
const removeParticipant = (i) =>
setParticipantInputs(p => p.filter((_, idx) => idx !== i))
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()
@@ -24,8 +19,7 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
setSending(true)
try {
// Resolve usernames to IDs via the search API
let payload = { type, body }
const payload = { type, body }
if (type === 'direct') {
const user = await resolveUsername(recipientInput.trim(), apiFetch)
@@ -33,11 +27,11 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
} else {
const resolved = await Promise.all(
participantInputs
.map(p => p.trim())
.map((entry) => entry.trim())
.filter(Boolean)
.map(u => resolveUsername(u, apiFetch))
.map((entry) => resolveUsername(entry, apiFetch)),
)
payload.participant_ids = resolved.map(u => u.id)
payload.participant_ids = resolved.map((user) => user.id)
payload.title = groupTitle.trim()
}
@@ -52,114 +46,126 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
} finally {
setSending(false)
}
}, [type, body, recipientInput, groupTitle, participantInputs, apiFetch, onCreated])
}, [apiFetch, body, groupTitle, onCreated, participantInputs, recipientInput, type])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">New Message</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<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>
{/* Type toggle */}
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4">
{['direct', 'group'].map(t => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`flex-1 py-1.5 text-sm font-medium transition-colors ${
type === t
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
{t === 'direct' ? '1:1 Message' : 'Group'}
<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-3">
<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="block text-xs font-medium text-gray-500 mb-1">Recipient username</label>
<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)}
onChange={(e) => setRecipient(e.target.value)}
placeholder="username"
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 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 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="block text-xs font-medium text-gray-500 mb-1">Group name</label>
<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)}
onChange={(e) => setGroupTitle(e.target.value)}
placeholder="Group name"
required
maxLength={120}
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 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 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="block text-xs font-medium text-gray-500 mb-1">Participants (usernames)</label>
{participantInputs.map((val, i) => (
<div key={i} className="flex gap-2 mb-1">
<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={val}
onChange={e => updateParticipant(i, e.target.value)}
placeholder={`Username ${i + 1}`}
value={value}
onChange={(e) => updateParticipant(index, e.target.value)}
placeholder={`Username ${index + 1}`}
required
className="flex-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
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(i)} className="text-gray-400 hover:text-red-500">×</button>
)}
{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>
))}
<button type="button" onClick={addParticipant} className="text-xs text-blue-500 hover:text-blue-700 mt-1">
+ Add participant
</button>
</div>
</>
)}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Message</label>
<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)}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your message…"
required
rows={3}
maxLength={5000}
className="w-full resize-none rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 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-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="text-xs text-red-500 bg-red-50 dark:bg-red-900/30 rounded px-2 py-1">{error}</p>
)}
{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 gap-2 justify-end pt-1">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">Cancel</button>
<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="px-4 py-2 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
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 ? 'Sending…' : 'Send'}
{sending ? 'Creating…' : 'Create conversation'}
</button>
</div>
</form>
@@ -168,10 +174,9 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
)
}
// ── Resolve username to user object via search API ───────────────────────────
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.`)
if (!user) throw new Error(`User \"${username}\" not found.`)
return user
}