messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -1,8 +1,14 @@
import React from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import CommentForm from '../comments/CommentForm'
import ReactionBar from '../comments/ReactionBar'
import { isFlood } from '../../utils/emojiFlood'
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const date = new Date(dateStr)
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
@@ -25,6 +31,10 @@ function Avatar({ user, size = 36 }) {
className="rounded-full object-cover shrink-0"
style={{ width: size, height: size }}
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = 'https://files.skinbase.org/avatars/default.webp'
}}
/>
)
}
@@ -39,59 +49,259 @@ function Avatar({ user, size = 36 }) {
)
}
export default function ArtworkComments({ comments = [] }) {
if (!comments || comments.length === 0) return null
// ── Single comment ────────────────────────────────────────────────────────────
function CommentItem({ comment, isLoggedIn }) {
const user = comment.user
const html = comment.rendered_content ?? null
const plain = comment.content ?? ''
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
const flood = isFlood(plain)
const [expanded, setExpanded] = useState(!flood)
// Build initial reaction totals (empty if not provided by server)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
// Load reactions lazily if not provided
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
.get(`/api/comments/${comment.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [comment.id, comment.reactions])
return (
<section aria-label="Comments">
<h2 className="text-base font-semibold text-white mb-4">
<li className="flex gap-3" id={`comment-${comment.id}`}>
{/* Avatar */}
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={36} />
</a>
) : (
<span className="shrink-0 mt-0.5">
<Avatar user={user} size={36} />
</span>
)}
{/* Content */}
<div className="min-w-0 flex-1 space-y-1.5">
{/* Header */}
<div className="flex items-baseline gap-2 flex-wrap">
{user?.profile_url ? (
<a href={user.profile_url} className="text-sm font-medium text-white hover:underline">
{user.display || user.username || user.name || 'Member'}
</a>
) : (
<span className="text-sm font-medium text-white">
{user?.display || user?.username || user?.name || 'Member'}
</span>
)}
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-xs text-neutral-500"
>
{comment.time_ago || timeAgo(comment.created_at)}
</time>
</div>
{/* Body — use rendered_content (safe HTML) when available, else plain text */}
{/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */}
<div
className={!expanded ? 'overflow-hidden relative' : undefined}
style={!expanded ? { maxHeight: '5em' } : undefined}
>
{html ? (
<div
className="text-sm text-neutral-300 leading-relaxed prose prose-invert prose-sm max-w-none
prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline
prose-code:bg-white/[0.07] prose-code:px-1 prose-code:rounded prose-code:text-xs"
// rendered_content is server-sanitized HTML safe to inject
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
{plain}
</p>
)}
{/* Gradient fade at the bottom while collapsed */}
{flood && !expanded && (
<div
className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-neutral-900 to-transparent pointer-events-none"
aria-hidden="true"
/>
)}
</div>
{/* Flood expand / collapse toggle */}
{flood && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="text-xs text-sky-400 hover:text-sky-300 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-500 rounded"
aria-expanded={expanded}
>
{expanded ? '▲\u2009Collapse' : '▼\u2009Show full comment'}
</button>
)}
{/* Reactions */}
{Object.keys(reactionTotals).length > 0 && (
<ReactionBar
entityType="comment"
entityId={comment.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
/>
)}
</div>
</li>
)
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function Skeleton() {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<div className="w-9 h-9 rounded-full bg-white/[0.07] shrink-0" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-3 bg-white/[0.07] rounded w-28" />
<div className="h-3 bg-white/[0.05] rounded w-full" />
<div className="h-3 bg-white/[0.04] rounded w-2/3" />
</div>
</div>
))}
</div>
)
}
// ── Main export ───────────────────────────────────────────────────────────────
/**
* ArtworkComments
*
* Can operate in two modes:
* 1. Static: pass `comments` array from Inertia page props (legacy / SSR)
* 2. Dynamic: pass `artworkId` to load + post comments via the API
*
* Props:
* artworkId number Used for API calls
* comments array SSR initial comments (optional)
* isLoggedIn boolean
* loginUrl string
*/
export default function ArtworkComments({
artworkId,
comments: initialComments = [],
isLoggedIn = false,
loginUrl = '/login',
}) {
const [comments, setComments] = useState(initialComments)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [lastPage, setLastPage] = useState(1)
const [total, setTotal] = useState(initialComments.length)
const initialized = useRef(false)
// Load comments from API
const loadComments = useCallback(
async (p = 1) => {
if (!artworkId) return
setLoading(true)
try {
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`)
if (p === 1) {
setComments(data.data ?? [])
} else {
setComments((prev) => [...prev, ...(data.data ?? [])])
}
setPage(data.meta?.current_page ?? p)
setLastPage(data.meta?.last_page ?? 1)
setTotal(data.meta?.total ?? 0)
} catch {
// keep existing data on error
} finally {
setLoading(false)
}
},
[artworkId],
)
// On mount, load if artworkId provided and no SSR comments given
useEffect(() => {
if (initialized.current) return
initialized.current = true
if (artworkId && initialComments.length === 0) {
loadComments(1)
} else {
setTotal(initialComments.length)
}
}, [artworkId, initialComments.length, loadComments])
const handlePosted = useCallback((newComment) => {
setComments((prev) => [newComment, ...prev])
setTotal((t) => t + 1)
}, [])
return (
<section aria-label="Comments" className="space-y-6">
<h2 className="text-base font-semibold text-white">
Comments{' '}
<span className="text-neutral-500 font-normal">({comments.length})</span>
{total > 0 && (
<span className="text-neutral-500 font-normal">({total})</span>
)}
</h2>
<ul className="space-y-5">
{comments.map((comment) => (
<li key={comment.id} className="flex gap-3">
{comment.user?.profile_url ? (
<a href={comment.user.profile_url} className="shrink-0 mt-0.5">
<Avatar user={comment.user} size={36} />
</a>
) : (
<span className="shrink-0 mt-0.5">
<Avatar user={comment.user} size={36} />
</span>
)}
{/* Comment form */}
{artworkId && (
<CommentForm
artworkId={artworkId}
onPosted={handlePosted}
isLoggedIn={isLoggedIn}
loginUrl={loginUrl}
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2 flex-wrap">
{comment.user?.profile_url ? (
<a
href={comment.user.profile_url}
className="text-sm font-medium text-white hover:underline"
>
{comment.user.name || comment.user.username || 'Member'}
</a>
) : (
<span className="text-sm font-medium text-white">
{comment.user?.name || comment.user?.username || 'Member'}
</span>
)}
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-xs text-neutral-500"
>
{timeAgo(comment.created_at)}
</time>
</div>
{/* Comment list */}
{loading && comments.length === 0 ? (
<Skeleton />
) : comments.length === 0 ? (
<p className="text-sm text-neutral-500">No comments yet. Be the first!</p>
) : (
<>
<ul className="space-y-5">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
isLoggedIn={isLoggedIn}
/>
))}
</ul>
<p className="mt-1 text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
{comment.content}
</p>
{/* Load more */}
{page < lastPage && (
<div className="flex justify-center pt-2">
<button
type="button"
disabled={loading}
onClick={() => loadComments(page + 1)}
className="px-5 py-2 rounded-lg text-sm text-white/60 border border-white/[0.08] hover:text-white hover:border-white/20 transition-colors disabled:opacity-40"
>
{loading ? 'Loading…' : 'Load more comments'}
</button>
</div>
</li>
))}
</ul>
)}
</>
)}
</section>
)
}

View File

@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import ReactionBar from '../comments/ReactionBar'
/**
* Loads and displays reactions for a single artwork.
*
* Props:
* artworkId number
* isLoggedIn boolean
*/
export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
const [totals, setTotals] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!artworkId) return
axios
.get(`/api/artworks/${artworkId}/reactions`)
.then(({ data }) => setTotals(data.totals ?? {}))
.catch(() => setTotals({}))
.finally(() => setLoading(false))
}, [artworkId])
if (loading) return null
if (!totals || Object.values(totals).every((r) => r.count === 0) && !isLoggedIn) {
return null
}
return (
<div className="mt-3">
<ReactionBar
entityType="artwork"
entityId={artworkId}
initialTotals={totals}
isLoggedIn={isLoggedIn}
/>
</div>
)
}

View File

@@ -0,0 +1,158 @@
import React, { useCallback, useRef, useState } from 'react'
import axios from 'axios'
import EmojiPickerButton from './EmojiPickerButton'
/**
* Comment form with emoji picker and Markdown-lite support.
*
* Props:
* artworkId number Target artwork
* onPosted (comment) => void Called when comment is successfully posted
* isLoggedIn boolean
* loginUrl string Where to redirect non-authenticated users
*/
export default function CommentForm({
artworkId,
onPosted,
isLoggedIn = false,
loginUrl = '/login',
}) {
const [content, setContent] = useState('')
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState([])
const textareaRef = useRef(null)
// Insert text at current cursor position
const insertAtCursor = useCallback((text) => {
const el = textareaRef.current
if (!el) {
setContent((v) => v + text)
return
}
const start = el.selectionStart ?? content.length
const end = el.selectionEnd ?? content.length
const next = content.slice(0, start) + text + content.slice(end)
setContent(next)
// Restore cursor after the inserted text
requestAnimationFrame(() => {
el.selectionStart = start + text.length
el.selectionEnd = start + text.length
el.focus()
})
}, [content])
const handleEmojiSelect = useCallback((emoji) => {
insertAtCursor(emoji)
}, [insertAtCursor])
const handleSubmit = useCallback(
async (e) => {
e.preventDefault()
if (!isLoggedIn) {
window.location.href = loginUrl
return
}
const trimmed = content.trim()
if (!trimmed) return
setSubmitting(true)
setErrors([])
try {
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
content: trimmed,
})
setContent('')
onPosted?.(data.data)
} catch (err) {
if (err.response?.status === 422) {
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
} else {
setErrors(['Something went wrong. Please try again.'])
}
} finally {
setSubmitting(false)
}
},
[artworkId, content, isLoggedIn, loginUrl, onPosted],
)
if (!isLoggedIn) {
return (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
Sign in
</a>{' '}
to leave a comment.
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-2">
{/* Textarea */}
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
rows={3}
maxLength={10000}
disabled={submitting}
aria-label="Comment text"
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
/>
{/* Toolbar at bottom-right of textarea */}
<div className="absolute bottom-2 right-3 flex items-center gap-2">
<span
className={[
'text-xs tabular-nums transition-colors',
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
].join(' ')}
aria-live="polite"
>
{content.length}/10 000
</span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
</div>
</div>
{/* Markdown hint */}
<p className="text-xs text-white/25 px-1">
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
</p>
{/* Errors */}
{errors.length > 0 && (
<ul className="space-y-1" role="alert">
{errors.map((e, i) => (
<li key={i} className="text-xs text-red-400 px-1">
{e}
</li>
))}
</ul>
)}
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={submitting || !content.trim()}
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
>
{submitting ? 'Posting…' : 'Post comment'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,287 @@
import React from 'react'
// ── Pagination ────────────────────────────────────────────────────────────────
function Pagination({ meta, onPageChange }) {
if (!meta || meta.last_page <= 1) return null
const { current_page, last_page } = meta
const pages = []
if (last_page <= 7) {
for (let i = 1; i <= last_page; i++) pages.push(i)
} else {
const around = new Set(
[1, last_page, current_page, current_page - 1, current_page + 1].filter(
(p) => p >= 1 && p <= last_page
)
)
const sorted = [...around].sort((a, b) => a - b)
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) pages.push('…')
pages.push(sorted[i])
}
}
return (
<nav
aria-label="Pagination"
className="mt-10 flex items-center justify-center gap-1 flex-wrap"
>
<button
disabled={current_page <= 1}
onClick={() => onPageChange(current_page - 1)}
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
aria-label="Previous page"
>
Prev
</button>
{pages.map((p, i) =>
p === '…' ? (
<span key={`sep-${i}`} className="px-2 text-white/25 text-sm select-none">
</span>
) : (
<button
key={p}
onClick={() => p !== current_page && onPageChange(p)}
aria-current={p === current_page ? 'page' : undefined}
className={[
'min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
p === current_page
? 'bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40'
: 'text-white/50 hover:text-white hover:bg-white/[0.06]',
].join(' ')}
>
{p}
</button>
)
)}
<button
disabled={current_page >= last_page}
onClick={() => onPageChange(current_page + 1)}
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
aria-label="Next page"
>
Next
</button>
</nav>
)
}
// ── Pin icon (for artwork reference) ─────────────────────────────────────────
function PinIcon() {
return (
<svg
className="w-3.5 h-3.5 shrink-0 text-white/30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}
// ── Artwork image icon (for right panel label) ────────────────────────────────
function ImageIcon() {
return (
<svg
className="w-3 h-3 shrink-0 text-white/25"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)
}
// ── Single comment row ────────────────────────────────────────────────────────
function CommentItem({ comment }) {
const { commenter, artwork, comment_text, time_ago, created_at } = comment
return (
<article className="flex gap-4 p-4 sm:p-5 rounded-xl border border-white/[0.065] bg-white/[0.025] hover:bg-white/[0.04] transition-colors">
{/* ── Avatar ── */}
<a
href={commenter.profile_url}
className="shrink-0 mt-0.5"
aria-label={`View ${commenter.display}'s profile`}
>
<img
src={commenter.avatar_url}
alt={commenter.display}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover ring-1 ring-white/[0.1]"
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
}}
/>
</a>
{/* ── Main content ── */}
<div className="min-w-0 flex-1">
{/* Author + time */}
<div className="flex items-baseline gap-2 flex-wrap mb-1">
<a
href={commenter.profile_url}
className="text-sm font-bold text-white hover:text-white/80 transition-colors"
>
{commenter.display}
</a>
<time
dateTime={created_at}
title={created_at ? new Date(created_at).toLocaleString() : ''}
className="text-xs text-white/40"
>
{time_ago}
</time>
</div>
{/* Comment text — primary visual element */}
<p className="text-base text-white leading-relaxed whitespace-pre-line break-words mb-3">
{comment_text}
</p>
{/* Artwork reference link */}
{artwork && (
<a
href={artwork.url}
className="inline-flex items-center gap-1.5 text-xs text-sky-400 hover:text-sky-300 transition-colors group"
>
<PinIcon />
<span className="font-medium">{artwork.title}</span>
</a>
)}
</div>
{/* ── Right: artwork thumbnail ── */}
{artwork?.thumb && (
<a
href={artwork.url}
className="shrink-0 self-start group"
tabIndex={-1}
aria-hidden="true"
>
<div className="w-[220px] overflow-hidden rounded-lg ring-1 ring-white/[0.07] group-hover:ring-white/20 transition-all">
<img
src={artwork.thumb}
alt={artwork.title ?? 'Artwork'}
width={220}
height={96}
className="w-full h-24 object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.closest('a').style.display = 'none' }}
/>
</div>
<div className="mt-1.5 px-0.5">
<div className="flex items-center gap-1 mb-0.5">
<ImageIcon />
<span className="text-[11px] text-white/45 truncate max-w-[200px]">{artwork.title}</span>
</div>
</div>
</a>
)}
</article>
)
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
function FeedSkeleton() {
return (
<div className="space-y-3 animate-pulse">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex gap-4 p-5 rounded-xl border border-white/[0.06] bg-white/[0.02]"
>
{/* avatar */}
<div className="w-12 h-12 rounded-full bg-white/[0.07] shrink-0" />
{/* content */}
<div className="flex-1 space-y-2 pt-1">
<div className="h-3 bg-white/[0.07] rounded w-32" />
<div className="h-4 bg-white/[0.06] rounded w-full" />
<div className="h-4 bg-white/[0.05] rounded w-3/4" />
<div className="h-3 bg-white/[0.04] rounded w-24 mt-2" />
</div>
{/* thumbnail */}
<div className="w-[220px] h-24 rounded-lg bg-white/[0.05] shrink-0" />
</div>
))}
</div>
)
}
// ── Empty state ───────────────────────────────────────────────────────────────
function EmptyState() {
return (
<div className="py-16 text-center">
<svg
className="mx-auto w-10 h-10 text-white/15 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 9.75a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 01.778-.332 48.294 48.294 0 005.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
<p className="text-white/30 text-sm">No comments found.</p>
</div>
)
}
// ── Main export ───────────────────────────────────────────────────────────────
export default function CommentsFeed({
comments = [],
meta = {},
loading = false,
error = null,
onPageChange,
}) {
if (loading) return <FeedSkeleton />
if (error) {
return (
<div className="rounded-xl border border-red-500/20 bg-red-900/10 px-6 py-5 text-sm text-red-400">
{error}
</div>
)
}
if (!comments || comments.length === 0) return <EmptyState />
return (
<div>
<div role="feed" aria-live="polite" aria-busy={loading} className="space-y-3">
{comments.map((comment) => (
<CommentItem key={comment.comment_id} comment={comment} />
))}
</div>
<Pagination meta={meta} onPageChange={onPageChange} />
</div>
)
}

View File

@@ -0,0 +1,92 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
/**
* A button that opens a floating emoji picker.
* When the user selects an emoji, `onEmojiSelect(emojiNative)` is called
* with the native Unicode character.
*
* Props:
* onEmojiSelect (string) → void Called with the emoji character
* disabled boolean Disables the button
* className string Additional classes for the trigger button
*/
export default function EmojiPickerButton({ onEmojiSelect, disabled = false, className = '' }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e) {
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
function handleKey(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
const handleSelect = useCallback(
(emoji) => {
onEmojiSelect?.(emoji.native)
setOpen(false)
},
[onEmojiSelect],
)
return (
<div ref={wrapRef} className="relative inline-block">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
aria-label="Open emoji picker"
aria-expanded={open}
className={[
'flex items-center justify-center w-8 h-8 rounded-md',
'text-white/40 hover:text-white/70 hover:bg-white/[0.07]',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-30 disabled:pointer-events-none',
className,
]
.filter(Boolean)
.join(' ')}
>
😊
</button>
{open && (
<div
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
>
<Picker
data={data}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
perLine={8}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import React, { useCallback, useOptimistic, useState } from 'react'
import axios from 'axios'
/**
* Reaction bar for an artwork or comment.
*
* Props:
* entityType 'artwork' | 'comment'
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean — if false, clicking shows a prompt
*/
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null) // slug being toggled
const endpoint =
entityType === 'artwork'
? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions`
const toggle = useCallback(
async (slug) => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
if (loading) return // prevent double-click
setLoading(slug)
// Optimistic update
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
mine: !entry.mine,
},
}
})
try {
const { data } = await axios.post(endpoint, { reaction: slug })
setTotals(data.totals)
} catch {
// Rollback
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
mine: !entry.mine,
},
}
})
} finally {
setLoading(null)
}
},
[endpoint, isLoggedIn, loading],
)
const entries = Object.entries(totals)
if (entries.length === 0) return null
return (
<div
role="group"
aria-label="Reactions"
className="flex flex-wrap items-center gap-1.5"
>
{entries.map(([slug, info]) => {
const { emoji, label, count, mine } = info
const isProcessing = loading === slug
return (
<button
key={slug}
type="button"
disabled={isProcessing}
onClick={() => toggle(slug)}
aria-label={`${label}${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
aria-pressed={mine}
className={[
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
'border transition-all duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-50 disabled:pointer-events-none',
mine
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
]
.filter(Boolean)
.join(' ')}
>
<span aria-hidden="true">{emoji}</span>
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
<span className="sr-only">{label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,123 @@
import React, { useMemo, useState } from 'react'
/**
* Left panel: searchable, paginated list of conversations.
*/
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) {
const [search, setSearch] = useState('')
const filtered = useMemo(() => {
const q = search.toLowerCase().trim()
if (!q) return conversations
return conversations.filter(conv => {
const label = convLabel(conv, currentUserId).toLowerCase()
const last = (conv.latest_message?.[0]?.body ?? '').toLowerCase()
return label.includes(q) || last.includes(q)
})
}, [conversations, search, currentUserId])
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Search */}
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
<input
type="search"
placeholder="Search conversations…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* List */}
<ul className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{loading && (
<li className="px-4 py-8 text-center text-sm text-gray-400">Loading</li>
)}
{!loading && filtered.length === 0 && (
<li className="px-4 py-8 text-center text-sm text-gray-400">
{search ? 'No matches found.' : 'No conversations yet.'}
</li>
)}
{filtered.map(conv => (
<ConversationRow
key={conv.id}
conv={conv}
isActive={conv.id === activeId}
currentUserId={currentUserId}
onClick={() => onSelect(conv.id)}
/>
))}
</ul>
</div>
)
}
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = lastMsg ? truncate(lastMsg.body, 60) : 'No messages yet'
const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find(p => p.user_id === currentUserId)
const isArchived = myParticipant?.is_archived ?? false
const isPinned = myParticipant?.is_pinned ?? false
return (
<li>
<button
onClick={onClick}
className={`w-full text-left px-4 py-3 flex gap-3 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors ${isActive ? 'bg-blue-50 dark:bg-blue-900/30' : ''} ${isArchived ? 'opacity-60' : ''}`}
>
{/* Avatar placeholder */}
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium">
{label.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1 min-w-0">
{isPinned && <span className="text-xs" title="Pinned">📌</span>}
<span className={`text-sm font-medium truncate ${isActive ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-gray-100'}`}>
{label}
</span>
</div>
{conv.last_message_at && (
<span className="text-xs text-gray-400 flex-shrink-0">
{relativeTime(conv.last_message_at)}
</span>
)}
</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-xs text-gray-400 truncate">{preview}</span>
{unread > 0 && (
<span className="flex-shrink-0 min-w-[1.25rem] h-5 rounded-full bg-blue-500 text-white text-xs font-medium flex items-center justify-center px-1">
{unread > 99 ? '99+' : unread}
</span>
)}
</div>
</div>
</button>
</li>
)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function convLabel(conv, currentUserId) {
if (conv.type === 'group') return conv.title ?? 'Group'
const other = conv.all_participants?.find(p => p.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 relativeTime(iso) {
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`
}

View File

@@ -0,0 +1,587 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import MessageBubble from './MessageBubble'
/**
* Right panel: scrollable thread of messages with send form.
*/
export default function ConversationThread({
conversationId,
conversation,
currentUserId,
currentUsername,
apiFetch,
onBack,
onMarkRead,
onConversationUpdated,
}) {
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [body, setBody] = useState('')
const [nextCursor, setNextCursor] = useState(null)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState(null)
const [attachments, setAttachments] = useState([])
const [typingUsers, setTypingUsers] = useState([])
const [threadSearch, setThreadSearch] = useState('')
const [threadSearchResults, setThreadSearchResults] = useState([])
const fileInputRef = useRef(null)
const bottomRef = useRef(null)
const threadRef = useRef(null)
const pollRef = useRef(null)
const typingPollRef = useRef(null)
const typingStopTimerRef = useRef(null)
const latestIdRef = useRef(null)
const shouldAutoScrollRef = useRef(true)
const draftKey = `nova_draft_${conversationId}`
// ── Initial load ─────────────────────────────────────────────────────────
const loadMessages = useCallback(async () => {
try {
const data = await apiFetch(`/api/messages/${conversationId}`)
const msgs = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
setMessages(msgs)
setNextCursor(data.next_cursor ?? null)
setLoading(false)
if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id
shouldAutoScrollRef.current = true
} catch (e) {
setError(e.message)
setLoading(false)
}
}, [conversationId, currentUserId, apiFetch])
useEffect(() => {
setLoading(true)
setMessages([])
const storedDraft = window.localStorage.getItem(draftKey)
setBody(storedDraft ?? '')
loadMessages()
// Phase 1 polling: check new messages every 10 seconds
pollRef.current = setInterval(async () => {
try {
const data = await apiFetch(`/api/messages/${conversationId}`)
const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
shouldAutoScrollRef.current = true
setMessages(prev => mergeMessageLists(prev, latestChunk))
latestIdRef.current = latestChunk[latestChunk.length - 1].id
onConversationUpdated()
}
} catch (_) {}
}, 10_000)
return () => clearInterval(pollRef.current)
}, [conversationId, draftKey])
useEffect(() => {
typingPollRef.current = setInterval(async () => {
try {
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
setTypingUsers(data.typing ?? [])
} catch (_) {}
}, 2_000)
return () => {
clearInterval(typingPollRef.current)
clearTimeout(typingStopTimerRef.current)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}
}, [conversationId, apiFetch])
useEffect(() => {
const content = body.trim()
if (!content) {
clearTimeout(typingStopTimerRef.current)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
return
}
apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
clearTimeout(typingStopTimerRef.current)
typingStopTimerRef.current = setTimeout(() => {
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}, 2500)
}, [body, conversationId, apiFetch])
useEffect(() => {
if (body.trim()) {
window.localStorage.setItem(draftKey, body)
return
}
window.localStorage.removeItem(draftKey)
}, [body, draftKey])
// ── Scroll to bottom on first load and new messages ───────────────────────
useEffect(() => {
if (!loading && shouldAutoScrollRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
shouldAutoScrollRef.current = false
}
}, [loading, messages.length])
// ── Mark as read when thread is viewed ────────────────────────────────────
useEffect(() => {
if (!loading) {
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
.then(() => onMarkRead(conversationId))
.catch(() => {})
}
}, [loading, conversationId])
// ── Load older messages ───────────────────────────────────────────────────
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
const container = threadRef.current
const prevHeight = container?.scrollHeight ?? 0
try {
const data = await apiFetch(`/api/messages/${conversationId}?cursor=${nextCursor}`)
const older = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
shouldAutoScrollRef.current = false
setMessages(prev => [...older, ...prev])
setNextCursor(data.next_cursor ?? null)
requestAnimationFrame(() => {
if (!container) return
const newHeight = container.scrollHeight
container.scrollTop = Math.max(0, newHeight - prevHeight + container.scrollTop)
})
} catch (_) {}
setLoadingMore(false)
}, [nextCursor, loadingMore, apiFetch, conversationId, currentUserId])
const handleThreadScroll = useCallback((e) => {
if (e.currentTarget.scrollTop < 120) {
loadMore()
}
}, [loadMore])
// ── Send message ──────────────────────────────────────────────────────────
const handleSend = useCallback(async (e) => {
e.preventDefault()
const text = body.trim()
if ((!text && attachments.length === 0) || sending) return
setSending(true)
const optimistic = {
id: `opt-${Date.now()}`,
sender_id: currentUserId,
sender: { id: currentUserId, username: currentUsername },
body: text,
created_at: new Date().toISOString(),
_optimistic: true,
attachments: attachments.map((file, index) => ({
id: `tmp-${Date.now()}-${index}`,
type: file.type.startsWith('image/') ? 'image' : 'file',
original_name: file.name,
})),
}
setMessages(prev => [...prev, optimistic])
setBody('')
window.localStorage.removeItem(draftKey)
shouldAutoScrollRef.current = true
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
try {
const formData = new FormData()
formData.append('body', text)
attachments.forEach(file => formData.append('attachments[]', file))
const msg = await apiFetch(`/api/messages/${conversationId}`, {
method: 'POST',
body: formData,
})
setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
latestIdRef.current = msg.id
onConversationUpdated()
setAttachments([])
} catch (e) {
setMessages(prev => prev.filter(m => m.id !== optimistic.id))
setError(e.message)
} finally {
setSending(false)
}
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend(e)
}
}, [handleSend])
// ── Reaction ──────────────────────────────────────────────────────────────
const handleReact = useCallback(async (messageId, emoji) => {
try {
await apiFetch(`/api/messages/${messageId}/reactions`, {
method: 'POST',
body: JSON.stringify({ reaction: emoji }),
})
// Optimistically add reaction with _iMine flag
setMessages(prev => prev.map(m => {
if (m.id !== messageId) return m
const existing = (m.reactions ?? []).some(r => r._iMine && r.reaction === emoji)
if (existing) return m
return { ...m, reactions: [...(m.reactions ?? []), { reaction: emoji, user_id: currentUserId, _iMine: true }] }
}))
} catch (_) {}
}, [currentUserId, apiFetch])
const handleUnreact = useCallback(async (messageId, emoji) => {
try {
await apiFetch(`/api/messages/${messageId}/reactions`, {
method: 'DELETE',
body: JSON.stringify({ reaction: emoji }),
})
// Optimistically remove reaction
setMessages(prev => prev.map(m => {
if (m.id !== messageId) return m
return { ...m, reactions: (m.reactions ?? []).filter(r => !(r._iMine && r.reaction === emoji)) }
}))
} catch (_) {}
}, [apiFetch])
const handleEdit = useCallback(async (messageId, newBody) => {
const updated = await apiFetch(`/api/messages/message/${messageId}`, {
method: 'PATCH',
body: JSON.stringify({ body: newBody }),
})
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, body: updated.body, edited_at: updated.edited_at } : m))
}, [apiFetch])
const handleReportMessage = useCallback(async (messageId) => {
try {
await apiFetch('/api/reports', {
method: 'POST',
body: JSON.stringify({
target_type: 'message',
target_id: messageId,
reason: 'inappropriate',
details: '',
}),
})
} catch (e) {
setError(e.message)
}
}, [apiFetch])
const handlePickAttachments = useCallback((e) => {
const next = Array.from(e.target.files ?? [])
if (!next.length) return
setAttachments(prev => [...prev, ...next].slice(0, 5))
e.target.value = ''
}, [])
const removeAttachment = useCallback((index) => {
setAttachments(prev => prev.filter((_, i) => i !== index))
}, [])
const togglePin = useCallback(async () => {
const me = conversation?.all_participants?.find(p => p.user_id === currentUserId)
const isPinned = !!me?.is_pinned
const endpoint = isPinned ? 'unpin' : 'pin'
try {
await apiFetch(`/api/messages/${conversationId}/${endpoint}`, { method: 'POST' })
onConversationUpdated()
} catch (e) {
setError(e.message)
}
}, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])
useEffect(() => {
let cancelled = false
const q = threadSearch.trim()
if (q.length < 2) {
setThreadSearchResults([])
return
}
const timer = setTimeout(async () => {
try {
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}&conversation_id=${conversationId}`)
if (!cancelled) {
setThreadSearchResults(data.data ?? [])
}
} catch (_) {
if (!cancelled) {
setThreadSearchResults([])
}
}
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [threadSearch, conversationId, apiFetch])
const jumpToMessage = useCallback((messageId) => {
const target = document.getElementById(`message-${messageId}`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [])
// ── Thread header label ───────────────────────────────────────────────────
const threadLabel = conversation?.type === 'group'
? (conversation?.title ?? 'Group conversation')
: (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
const otherLastReadAt = otherParticipant?.last_read_at ?? null
const lastMessageId = messages[messages.length - 1]?.id ?? null
// ── Group date separators from messages ──────────────────────────────────
const grouped = groupByDate(messages)
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
onClick={onBack}
className="sm:hidden p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
</button>
<div>
<p className="font-medium text-sm text-gray-900 dark:text-gray-100">{threadLabel}</p>
{conversation?.type === 'group' && (
<p className="text-xs text-gray-400">
{conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
</p>
)}
{typingUsers.length > 0 && (
<p className="text-xs text-blue-400">{typingUsers.map(u => u.username).join(', ')} typing</p>
)}
</div>
<button
type="button"
onClick={togglePin}
className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
{conversation?.my_participant?.is_pinned ? 'Unpin' : 'Pin'}
</button>
</div>
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<input
type="search"
value={threadSearch}
onChange={e => setThreadSearch(e.target.value)}
placeholder="Search in this conversation…"
className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500"
/>
{threadSearch.trim().length >= 2 && (
<div className="mt-2 max-h-28 overflow-y-auto rounded border border-gray-200 dark:border-gray-700">
{threadSearchResults.length === 0 && (
<p className="px-2 py-1 text-xs text-gray-400">No matches</p>
)}
{threadSearchResults.map(item => (
<button
key={`thread-search-${item.id}`}
onClick={() => jumpToMessage(item.id)}
className="w-full text-left px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-gray-500">@{item.sender?.username ?? 'unknown'}: </span>
<span className="text-gray-800 dark:text-gray-200">{item.body || '(attachment)'}</span>
</button>
))}
</div>
)}
</div>
{/* Messages */}
<div ref={threadRef} onScroll={handleThreadScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
{loadingMore && (
<div className="text-center py-2 text-xs text-gray-400">Loading older messages</div>
)}
{loading && (
<div className="text-center text-sm text-gray-400 py-12">Loading messages</div>
)}
{!loading && messages.length === 0 && (
<div className="text-center text-sm text-gray-400 py-12">
No messages yet. Say hello!
</div>
)}
{grouped.map(({ date, messages: dayMessages }) => (
<React.Fragment key={date}>
<div className="flex items-center gap-3 my-4">
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
<span className="text-xs text-gray-400 flex-shrink-0">{date}</span>
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
</div>
{dayMessages.map((msg, idx) => (
<div key={msg.id} id={`message-${msg.id}`}>
<MessageBubble
message={msg}
isMine={msg.sender_id === currentUserId}
showAvatar={idx === 0 || dayMessages[idx - 1]?.sender_id !== msg.sender_id}
onReact={handleReact}
onUnreact={handleUnreact}
onEdit={handleEdit}
onReport={handleReportMessage}
seenText={buildSeenText({
message: msg,
isMine: msg.sender_id === currentUserId,
isDirect: conversation?.type === 'direct',
isLastMessage: msg.id === lastMessageId,
otherLastReadAt,
})}
/>
</div>
))}
</React.Fragment>
))}
<div ref={bottomRef} />
</div>
{/* Error */}
{error && (
<div className="mx-4 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/30 rounded-lg">
{error}
</div>
)}
{/* Compose */}
<form onSubmit={handleSend} className="flex gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickAttachments}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-600 dark:text-gray-300"
title="Attach files"
>
📎
</button>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Write a message… (Enter to send, Shift+Enter for new line)"
rows={1}
maxLength={5000}
className="flex-1 resize-none rounded-xl 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 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto"
style={{ minHeight: '2.5rem' }}
/>
<button
type="submit"
disabled={(!body.trim() && attachments.length === 0) || sending}
className="flex-shrink-0 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white px-4 py-2 text-sm font-medium transition-colors"
>
Send
</button>
</form>
{attachments.length > 0 && (
<div className="px-4 pb-3 flex flex-wrap gap-2">
{attachments.map((file, idx) => (
<div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
<span className="truncate max-w-[220px]">{file.name}</span>
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500"></button>
</div>
))}
</div>
)}
</div>
)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function tagReactions(msg, currentUserId) {
if (!msg.reactions?.length) return msg
return {
...msg,
reactions: msg.reactions.map(r => ({ ...r, _iMine: r.user_id === currentUserId })),
}
}
function groupByDate(messages) {
const map = new Map()
for (const msg of messages) {
const date = formatDate(msg.created_at)
if (!map.has(date)) map.set(date, [])
map.get(date).push(msg)
}
return Array.from(map.entries()).map(([date, messages]) => ({ date, messages }))
}
function mergeMessageLists(existing, incoming) {
const byId = new Map()
for (const msg of existing) {
byId.set(String(msg.id), msg)
}
for (const msg of incoming) {
byId.set(String(msg.id), msg)
}
return Array.from(byId.values()).sort((a, b) => {
const at = new Date(a.created_at).getTime()
const bt = new Date(b.created_at).getTime()
if (at !== bt) return at - bt
const aid = Number(a.id)
const bid = Number(b.id)
if (!Number.isNaN(aid) && !Number.isNaN(bid)) {
return aid - bid
}
return String(a.id).localeCompare(String(b.id))
})
}
function buildSeenText({ message, isMine, isDirect, isLastMessage, otherLastReadAt }) {
if (!isDirect || !isMine || !isLastMessage || !otherLastReadAt || !message?.created_at) {
return null
}
const seenAt = new Date(otherLastReadAt)
const sentAt = new Date(message.created_at)
if (Number.isNaN(seenAt.getTime()) || Number.isNaN(sentAt.getTime()) || seenAt < sentAt) {
return null
}
return `Seen ${relativeTimeFromNow(otherLastReadAt)} ago`
}
function relativeTimeFromNow(iso) {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
return `${Math.floor(seconds / 86400)}d`
}
function formatDate(iso) {
const d = new Date(iso)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
if (isSameDay(d, today)) return 'Today'
if (isSameDay(d, yesterday)) return 'Yesterday'
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}

View File

@@ -0,0 +1,252 @@
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, 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' ? (
<a href={`/messages/attachments/${att.id}`} target="_blank" rel="noopener noreferrer">
<img
src={`/messages/attachments/${att.id}`}
alt={att.original_name}
className="max-h-44 rounded-lg border border-white/20"
loading="lazy"
/>
</a>
) : (
<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"
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 }))
}

View File

@@ -0,0 +1,177 @@
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 [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(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 handleSubmit = useCallback(async (e) => {
e.preventDefault()
setError(null)
setSending(true)
try {
// Resolve usernames to IDs via the search API
let 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(p => p.trim())
.filter(Boolean)
.map(u => resolveUsername(u, apiFetch))
)
payload.participant_ids = resolved.map(u => u.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)
}
}, [type, body, recipientInput, groupTitle, participantInputs, apiFetch, onCreated])
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'}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{type === 'direct' ? (
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Recipient username</label>
<input
type="text"
value={recipientInput}
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"
/>
</div>
) : (
<>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Group name</label>
<input
type="text"
value={groupTitle}
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"
/>
</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">
<input
type="text"
value={val}
onChange={e => updateParticipant(i, e.target.value)}
placeholder={`Username ${i + 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"
/>
{participantInputs.length > 2 && (
<button type="button" onClick={() => removeParticipant(i)} className="text-gray-400 hover:text-red-500">×</button>
)}
</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>
<textarea
value={body}
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"
/>
</div>
{error && (
<p className="text-xs text-red-500 bg-red-50 dark:bg-red-900/30 rounded px-2 py-1">{error}</p>
)}
<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>
<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"
>
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</form>
</div>
</div>
)
}
// ── 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.`)
return user
}