optimizations
This commit is contained in:
197
resources/js/components/profile/activity/ActivityCard.jsx
Normal file
197
resources/js/components/profile/activity/ActivityCard.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from 'react'
|
||||
|
||||
function typeMeta(type) {
|
||||
switch (type) {
|
||||
case 'upload':
|
||||
return { icon: 'fa-solid fa-image', label: 'Upload', tone: 'text-sky-200 bg-sky-400/12 border-sky-300/20' }
|
||||
case 'comment':
|
||||
return { icon: 'fa-solid fa-comment-dots', label: 'Comment', tone: 'text-amber-100 bg-amber-400/12 border-amber-300/20' }
|
||||
case 'reply':
|
||||
return { icon: 'fa-solid fa-reply', label: 'Reply', tone: 'text-orange-100 bg-orange-400/12 border-orange-300/20' }
|
||||
case 'like':
|
||||
return { icon: 'fa-solid fa-heart', label: 'Like', tone: 'text-rose-100 bg-rose-400/12 border-rose-300/20' }
|
||||
case 'favourite':
|
||||
return { icon: 'fa-solid fa-bookmark', label: 'Favourite', tone: 'text-pink-100 bg-pink-400/12 border-pink-300/20' }
|
||||
case 'follow':
|
||||
return { icon: 'fa-solid fa-user-plus', label: 'Follow', tone: 'text-emerald-100 bg-emerald-400/12 border-emerald-300/20' }
|
||||
case 'achievement':
|
||||
return { icon: 'fa-solid fa-trophy', label: 'Achievement', tone: 'text-yellow-100 bg-yellow-400/12 border-yellow-300/20' }
|
||||
case 'forum_post':
|
||||
return { icon: 'fa-solid fa-signs-post', label: 'Forum thread', tone: 'text-violet-100 bg-violet-400/12 border-violet-300/20' }
|
||||
case 'forum_reply':
|
||||
return { icon: 'fa-solid fa-comments', label: 'Forum reply', tone: 'text-indigo-100 bg-indigo-400/12 border-indigo-300/20' }
|
||||
default:
|
||||
return { icon: 'fa-solid fa-bolt', label: 'Activity', tone: 'text-slate-100 bg-white/6 border-white/10' }
|
||||
}
|
||||
}
|
||||
|
||||
function profileName(actor) {
|
||||
if (!actor) return 'Creator'
|
||||
return actor.username ? `@${actor.username}` : actor.name || 'Creator'
|
||||
}
|
||||
|
||||
function headline(activity) {
|
||||
switch (activity?.type) {
|
||||
case 'upload':
|
||||
return activity?.artwork?.title ? `Uploaded ${activity.artwork.title}` : 'Uploaded new artwork'
|
||||
case 'comment':
|
||||
return activity?.artwork?.title ? `Commented on ${activity.artwork.title}` : 'Posted a new comment'
|
||||
case 'reply':
|
||||
return activity?.artwork?.title ? `Replied on ${activity.artwork.title}` : 'Posted a reply'
|
||||
case 'like':
|
||||
return activity?.artwork?.title ? `Liked ${activity.artwork.title}` : 'Liked an artwork'
|
||||
case 'favourite':
|
||||
return activity?.artwork?.title ? `Favourited ${activity.artwork.title}` : 'Saved an artwork'
|
||||
case 'follow':
|
||||
return activity?.target_user ? `Started following @${activity.target_user.username || activity.target_user.name}` : 'Started following a creator'
|
||||
case 'achievement':
|
||||
return activity?.achievement?.name ? `Unlocked ${activity.achievement.name}` : 'Unlocked a new achievement'
|
||||
case 'forum_post':
|
||||
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
|
||||
case 'forum_reply':
|
||||
return activity?.forum?.thread?.title ? `Replied in ${activity.forum.thread.title}` : 'Posted a forum reply'
|
||||
default:
|
||||
return 'Shared new activity'
|
||||
}
|
||||
}
|
||||
|
||||
function body(activity) {
|
||||
if (activity?.comment?.body) return activity.comment.body
|
||||
if (activity?.forum?.post?.excerpt) return activity.forum.post.excerpt
|
||||
if (activity?.achievement?.description) return activity.achievement.description
|
||||
return ''
|
||||
}
|
||||
|
||||
function cta(activity) {
|
||||
if (activity?.comment?.url) return { href: activity.comment.url, label: 'Open comment' }
|
||||
if (activity?.artwork?.url) return { href: activity.artwork.url, label: 'View artwork' }
|
||||
if (activity?.forum?.post?.url) return { href: activity.forum.post.url, label: 'Open reply' }
|
||||
if (activity?.forum?.thread?.url) return { href: activity.forum.thread.url, label: 'Open thread' }
|
||||
if (activity?.target_user?.profile_url) return { href: activity.target_user.profile_url, label: 'View profile' }
|
||||
return null
|
||||
}
|
||||
|
||||
function AchievementIcon({ achievement }) {
|
||||
const raw = String(achievement?.icon || '').trim()
|
||||
const className = raw.startsWith('fa-') ? raw : `fa-solid ${raw || 'fa-trophy'}`
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-yellow-300/20 bg-yellow-400/12 text-yellow-100">
|
||||
<i className={className} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ActivityCard({ activity }) {
|
||||
const meta = typeMeta(activity?.type)
|
||||
const nextAction = cta(activity)
|
||||
const copy = body(activity)
|
||||
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-5 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start">
|
||||
<div className="flex items-start gap-4 md:w-[17rem] md:shrink-0">
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/70">
|
||||
{activity?.actor?.avatar_url ? (
|
||||
<img src={activity.actor.avatar_url} alt={profileName(activity.actor)} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-user" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{profileName(activity.actor)}</div>
|
||||
{activity?.actor?.badge?.label ? (
|
||||
<div className="mt-1 inline-flex items-center rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
{activity.actor.badge.label}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">{activity?.time_ago || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${meta.tone}`}>
|
||||
<i className={meta.icon} />
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold tracking-[-0.02em] text-white">{headline(activity)}</h3>
|
||||
{copy ? <p className="mt-2 max-w-3xl text-sm leading-7 text-slate-400">{copy}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 md:text-right">{activity?.created_at ? new Date(activity.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''}</div>
|
||||
</div>
|
||||
|
||||
{activity?.artwork ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
{activity.artwork.thumb ? (
|
||||
<img src={activity.artwork.thumb} alt={activity.artwork.title} className="h-16 w-16 rounded-2xl object-cover ring-1 ring-white/10" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">{activity.artwork.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
<span>{Number(activity.artwork.stats?.likes || 0).toLocaleString()} likes</span>
|
||||
<span>{Number(activity.artwork.stats?.views || 0).toLocaleString()} views</span>
|
||||
<span>{Number(activity.artwork.stats?.comments || 0).toLocaleString()} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.target_user ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/70">
|
||||
{activity.target_user.avatar_url ? (
|
||||
<img src={activity.target_user.avatar_url} alt={activity.target_user.username || activity.target_user.name} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Target creator</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">@{activity.target_user.username || activity.target_user.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.achievement ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<AchievementIcon achievement={activity.achievement} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Achievement unlocked</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">{activity.achievement.name}</div>
|
||||
{activity.achievement.description ? <div className="mt-1 text-sm text-slate-400">{activity.achievement.description}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.forum?.thread ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Forum activity</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">{activity.forum.thread.title}</div>
|
||||
<div className="mt-2 text-xs text-slate-400">{activity.forum.thread.category_name}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{nextAction ? (
|
||||
<a
|
||||
href={nextAction.href}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white"
|
||||
>
|
||||
{nextAction.label}
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
63
resources/js/components/profile/activity/ActivityFeed.jsx
Normal file
63
resources/js/components/profile/activity/ActivityFeed.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import ActivityCard from './ActivityCard'
|
||||
|
||||
export default function ActivityFeed({ activities, loading, loadingMore, error, sentinelRef }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-6 text-sm text-slate-400 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
Loading activity...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-rose-300/20 bg-rose-500/10 p-6 text-sm text-rose-100 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activities.length) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] px-6 py-12 text-center shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-sky-300">
|
||||
<i className="fa-solid fa-wave-square text-xl" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold text-white">No activity yet</h3>
|
||||
<p className="mx-auto mt-2 max-w-lg text-sm leading-7 text-slate-400">
|
||||
Upload artwork, join a conversation, follow creators, or post in the forum to start building this profile timeline.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href="/upload" className="inline-flex items-center gap-2 rounded-full border border-sky-300/30 bg-sky-400/12 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100 transition hover:border-sky-200/50 hover:bg-sky-400/18">
|
||||
<i className="fa-solid fa-upload" />
|
||||
Upload artwork
|
||||
</a>
|
||||
<a href="/uploads/latest" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
|
||||
<i className="fa-solid fa-comment-dots" />
|
||||
Comment on artwork
|
||||
</a>
|
||||
<a href="/discover/trending" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
|
||||
<i className="fa-solid fa-user-plus" />
|
||||
Follow creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<ActivityCard key={activity.id} activity={activity} />
|
||||
))}
|
||||
|
||||
<div ref={sentinelRef} className="h-12" aria-hidden="true" />
|
||||
|
||||
{loadingMore ? (
|
||||
<div className="text-center text-sm text-slate-400">Loading more activity...</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
resources/js/components/profile/activity/ActivityFilters.jsx
Normal file
36
resources/js/components/profile/activity/ActivityFilters.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'uploads', label: 'Uploads' },
|
||||
{ key: 'comments', label: 'Comments' },
|
||||
{ key: 'likes', label: 'Likes' },
|
||||
{ key: 'forum', label: 'Forum' },
|
||||
{ key: 'following', label: 'Following' },
|
||||
]
|
||||
|
||||
export default function ActivityFilters({ activeFilter, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTERS.map((filter) => {
|
||||
const active = activeFilter === filter.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
onClick={() => onChange(filter.key)}
|
||||
className={[
|
||||
'inline-flex items-center rounded-full border px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] transition-all',
|
||||
active
|
||||
? 'border-sky-300/35 bg-sky-400/14 text-sky-100 shadow-[0_0_0_1px_rgba(125,211,252,0.1)]'
|
||||
: 'border-white/10 bg-white/[0.04] text-slate-300 hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
resources/js/components/profile/activity/ActivityTab.jsx
Normal file
138
resources/js/components/profile/activity/ActivityTab.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ActivityFeed from './ActivityFeed'
|
||||
import ActivityFilters from './ActivityFilters'
|
||||
|
||||
function endpointForUser(user) {
|
||||
return `/api/profile/${encodeURIComponent(user.username || user.name || '')}/activity`
|
||||
}
|
||||
|
||||
export default function ActivityTab({ user }) {
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
const [activities, setActivities] = useState([])
|
||||
const [meta, setMeta] = useState({ current_page: 1, has_more: false, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const requestIdRef = useRef(0)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
|
||||
if (append) {
|
||||
setLoadingMore(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
setError('')
|
||||
const params = new URLSearchParams({
|
||||
filter,
|
||||
page: String(page),
|
||||
per_page: '20',
|
||||
})
|
||||
|
||||
const response = await fetch(`${endpointForUser(user)}?${params.toString()}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile activity.')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
setActivities((current) => append ? [...current, ...(payload.data || [])] : (payload.data || []))
|
||||
setMeta(payload.meta || { current_page: page, has_more: false, total: 0 })
|
||||
} catch {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError('Could not load this activity timeline right now.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed({ filter: activeFilter, page: 1, append: false })
|
||||
}, [activeFilter, fetchFeed])
|
||||
|
||||
const hasMore = Boolean(meta?.has_more)
|
||||
const nextPage = Number(meta?.current_page || 1) + 1
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || loading || loadingMore || !hasMore || !('IntersectionObserver' in window)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||
}
|
||||
}, { rootMargin: '240px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = Number(meta?.total || activities.length || 0)
|
||||
return total ? `${total.toLocaleString()} recent actions` : 'No recent actions'
|
||||
}, [activities.length, meta?.total])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-activity"
|
||||
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
|
||||
>
|
||||
<div className="rounded-[32px] border border-white/[0.06] bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(10,16,26,0.94),rgba(249,115,22,0.08))] p-5 shadow-[0_22px_70px_rgba(0,0,0,0.26)] md:p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">Activity</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Recent actions and contributions</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-[15px]">
|
||||
A living timeline of uploads, discussions, follows, achievements, and forum participation from {user.username || user.name}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="self-start rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<ActivityFilters activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<i className="fa-solid fa-bolt text-sky-300" />
|
||||
Timeline updates automatically as new actions are logged
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<ActivityFeed
|
||||
activities={activities}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user