Browse Source

revamp profile feeds

imwald
Silberengel 4 weeks ago
parent
commit
f4d2480328
  1. 56
      src/components/Profile/ProfileBadges.tsx
  2. 16
      src/components/Profile/ProfileFeed.tsx
  3. 298
      src/components/Profile/ProfileLikedFeed.tsx
  4. 101
      src/components/Profile/ProfileMediaFeed.tsx
  5. 50
      src/components/Profile/ProfilePublicationsFeed.tsx
  6. 68
      src/components/Profile/ProfileReportsDialog.tsx
  7. 117
      src/components/Profile/ProfileWallFeed.tsx
  8. 149
      src/components/Profile/index.tsx
  9. 12
      src/components/ProfileOptions/index.tsx
  10. 3
      src/constants.ts
  11. 2
      src/hooks/useProfileAuthorFeedSubRequests.ts
  12. 2
      src/i18n/locales/en.ts
  13. 2
      src/lib/profile-author-warmup-spec.test.ts

56
src/components/Profile/ProfileBadges.tsx

@ -0,0 +1,56 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useProfileWall } from '@/hooks/useProfileWall'
import { useTranslation } from 'react-i18next'
export default function ProfileBadges({
pubkey,
profileEventId
}: {
pubkey: string
profileEventId?: string
}) {
const { t } = useTranslation()
const { badges, isLoading } = useProfileWall(pubkey, profileEventId)
if (isLoading && badges.length === 0) {
return (
<div className="mt-3 flex flex-wrap gap-2" aria-hidden>
<Skeleton className="h-14 w-14 rounded-lg" />
<Skeleton className="h-14 w-14 rounded-lg" />
</div>
)
}
if (badges.length === 0) return null
return (
<section className="mt-3 min-w-0" aria-label={t('Badges')}>
<div className="flex flex-wrap gap-2">
{badges.map((badge) => (
<div
key={`${badge.definitionCoordinate}:${badge.awardEventId}`}
className="flex max-w-[5.5rem] flex-col items-center gap-0.5"
title={badge.description ?? badge.name}
>
{badge.imageUrl ? (
<img
src={badge.imageUrl}
alt={badge.name}
className="h-14 w-14 rounded-lg border border-border object-cover"
loading="lazy"
/>
) : (
<div
className="flex h-14 w-14 items-center justify-center rounded-lg border border-border bg-muted px-1 text-center text-[10px] font-medium leading-tight"
aria-hidden
>
{badge.name}
</div>
)}
<span className="w-full truncate text-center text-[10px] text-muted-foreground">{badge.name}</span>
</div>
))}
</div>
</section>
)
}

16
src/components/Profile/ProfileFeedWithPins.tsx → src/components/Profile/ProfileFeed.tsx

@ -3,7 +3,7 @@ import NoteCard from '@/components/NoteCard'
import KindFilter from '@/components/KindFilter' import KindFilter from '@/components/KindFilter'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, PROFILE_POSTS_TAB_KINDS } from '@/constants' import { ExtendedKind, PROFILE_FEED_KINDS, PROFILE_TIMELINE_REQ_LIMIT } from '@/constants'
import { useProfileAuthorFeedSubRequests } from '@/hooks/useProfileAuthorFeedSubRequests' import { useProfileAuthorFeedSubRequests } from '@/hooks/useProfileAuthorFeedSubRequests'
import { useProfilePins } from '@/hooks/useProfilePins' import { useProfilePins } from '@/hooks/useProfilePins'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
@ -13,7 +13,9 @@ import { nip19, kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const profileFeedKinds = [...PROFILE_FEED_KINDS]
const ProfileFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
@ -32,13 +34,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey)
const postsTabKinds = useMemo(() => [...PROFILE_POSTS_TAB_KINDS], [])
const { subRequests, followingFeedDeltaSubRequests, feedSubscriptionKey, refresh: refreshAuthorRelayLayers } = const { subRequests, followingFeedDeltaSubRequests, feedSubscriptionKey, refresh: refreshAuthorRelayLayers } =
useProfileAuthorFeedSubRequests({ useProfileAuthorFeedSubRequests({
pubkey, pubkey,
kinds: postsTabKinds, kinds: profileFeedKinds,
limit: 200 limit: PROFILE_TIMELINE_REQ_LIMIT
}) })
const pinnedEventIds = useMemo( const pinnedEventIds = useMemo(
@ -146,6 +146,6 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
) )
}) })
ProfileFeedWithPins.displayName = 'ProfileFeedWithPins' ProfileFeed.displayName = 'ProfileFeed'
export default ProfileFeedWithPins export default ProfileFeed

298
src/components/Profile/ProfileLikedFeed.tsx

@ -1,298 +0,0 @@
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { generateBech32IdFromATag, getFirstHexEventIdFromETags } from '@/lib/tag'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { RefreshCw } from 'lucide-react'
import { kinds, type Event } from 'nostr-tools'
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
const LIKED_REACTION_KINDS = [kinds.Reaction, ExtendedKind.EXTERNAL_REACTION]
type ReactionTargetRef = {
key: string
fetchId: string
hexId?: string
relayHints: string[]
}
type LikedTarget = {
reaction: Event
target: Event
}
function isPositiveReaction(event: Event): boolean {
return event.content.trim() !== '-'
}
function relayHintsForReactionTarget(event: Event, tag?: string[]): string[] {
const hints = new Set(relayHintsFromEventTags(event))
const tagHint = tag?.[2]?.trim()
if (tagHint) hints.add(tagHint)
return [...hints]
}
function reactionTargetRef(event: Event): ReactionTargetRef | null {
const hexId = getFirstHexEventIdFromETags(event.tags)
if (hexId) {
return {
key: `e:${hexId.toLowerCase()}`,
fetchId: hexId,
hexId,
relayHints: relayHintsForReactionTarget(event)
}
}
const addressTag = event.tags.find((tag) => (tag[0] === 'a' || tag[0] === 'A') && tag[1])
if (!addressTag) return null
const bech32Id = generateBech32IdFromATag(addressTag)
if (!bech32Id) return null
return {
key: `a:${addressTag[1]}`,
fetchId: bech32Id,
relayHints: relayHintsForReactionTarget(event, addressTag)
}
}
function samePubkey(a: string, b: string): boolean {
try {
return hexPubkeysEqual(normalizeHexPubkey(a), normalizeHexPubkey(b))
} catch {
return a === b
}
}
function newestReactionTargets(reactions: Event[]): Array<{ reaction: Event; targetRef: ReactionTargetRef }> {
const byTarget = new Map<string, { reaction: Event; targetRef: ReactionTargetRef }>()
for (const reaction of reactions) {
if (!isPositiveReaction(reaction)) continue
const targetRef = reactionTargetRef(reaction)
if (!targetRef) continue
const existing = byTarget.get(targetRef.key)
if (
!existing ||
reaction.created_at > existing.reaction.created_at ||
(reaction.created_at === existing.reaction.created_at && reaction.id > existing.reaction.id)
) {
byTarget.set(targetRef.key, { reaction, targetRef })
}
}
return [...byTarget.values()].sort((a, b) => b.reaction.created_at - a.reaction.created_at)
}
const ProfileLikedFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent()
const [isRefreshing, setIsRefreshing] = useState(false)
const [isResolvingTargets, setIsResolvingTargets] = useState(false)
const [likedTargets, setLikedTargets] = useState<LikedTarget[]>([])
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const reactionKinds = useMemo(() => [...LIKED_REACTION_KINDS], [])
const cacheKey = useMemo(() => `${pubkey}-profile-liked-v1`, [pubkey])
const { events: reactionEvents, isLoading, refresh } = useProfileTimeline({
pubkey,
cacheKey,
kinds: reactionKinds,
limit: 200
})
const targetRefs = useMemo(() => newestReactionTargets(reactionEvents), [reactionEvents])
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
}, [pubkey])
useEffect(() => {
if (!isLoading && !isResolvingTargets) {
setIsRefreshing(false)
}
}, [isLoading, isResolvingTargets])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
}
}),
[refresh]
)
useEffect(() => {
let cancelled = false
const viewerPubkey = pubkey
const toLikedTarget = (row: { reaction: Event; target: Event }): LikedTarget | null => {
if (samePubkey(row.target.pubkey, viewerPubkey)) return null
if (isEventDeleted(row.target)) return null
return row
}
const cachedRows = targetRefs
.map(({ reaction, targetRef }) => {
const cached = targetRef.hexId ? client.peekSessionCachedEvent(targetRef.hexId) : undefined
return cached ? toLikedTarget({ reaction, target: cached }) : null
})
.filter((row): row is LikedTarget => !!row)
setLikedTargets(cachedRows)
if (targetRefs.length === 0) {
setIsResolvingTargets(false)
return () => {
cancelled = true
}
}
setIsResolvingTargets(true)
void (async () => {
try {
const hexIds = targetRefs.map(({ targetRef }) => targetRef.hexId).filter((id): id is string => !!id)
if (hexIds.length > 0) {
const [archived, publications] = await Promise.all([
indexedDb.getArchivedEventsByIds(hexIds),
Promise.all(hexIds.map((id) => indexedDb.getEventFromPublicationStore(id)))
])
if (cancelled) return
const localById = new Map<string, Event>()
for (const event of archived) localById.set(event.id, event)
for (const event of publications) {
if (event) localById.set(event.id, event)
}
const localResolved = targetRefs
.map(({ reaction, targetRef }) => {
const target = targetRef.hexId ? localById.get(targetRef.hexId) : undefined
return target ? toLikedTarget({ reaction, target }) : null
})
.filter((row): row is LikedTarget => !!row)
if (localResolved.length > 0) {
setLikedTargets((prev) => {
const byTargetId = new Map(prev.map((row) => [row.target.id, row]))
for (const row of localResolved) byTargetId.set(row.target.id, row)
return [...byTargetId.values()].sort((a, b) => b.reaction.created_at - a.reaction.created_at)
})
}
}
const missingHexIds = targetRefs
.map(({ targetRef }) => targetRef.hexId)
.filter((id): id is string => !!id && !client.peekSessionCachedEvent(id))
if (missingHexIds.length > 0) {
await client.prefetchHexEventIds(missingHexIds)
}
const resolved = await Promise.all(
targetRefs.map(async ({ reaction, targetRef }) => {
const target =
(targetRef.hexId ? client.peekSessionCachedEvent(targetRef.hexId) : undefined) ??
await client.fetchEvent(targetRef.fetchId, { relayHints: targetRef.relayHints })
if (!target) return null
return toLikedTarget({ reaction, target })
})
)
if (cancelled) return
const byTargetId = new Map<string, LikedTarget>()
for (const row of resolved) {
if (!row) continue
const existing = byTargetId.get(row.target.id)
if (
!existing ||
row.reaction.created_at > existing.reaction.created_at ||
(row.reaction.created_at === existing.reaction.created_at && row.reaction.id > existing.reaction.id)
) {
byTargetId.set(row.target.id, row)
}
}
setLikedTargets([...byTargetId.values()].sort((a, b) => b.reaction.created_at - a.reaction.created_at))
} finally {
if (!cancelled) setIsResolvingTargets(false)
}
})()
return () => {
cancelled = true
}
}, [targetRefs, pubkey, isEventDeleted])
const displayedTargets = useMemo(
() => likedTargets.slice(0, showCount),
[likedTargets, showCount]
)
useEffect(() => {
if (!bottomRef.current || displayedTargets.length >= likedTargets.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedTargets.length < likedTargets.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, likedTargets.length))
}
},
{ threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => observer.disconnect()
}, [displayedTargets.length, likedTargets.length])
if ((isLoading || isResolvingTargets) && likedTargets.length === 0) {
return (
<div className="mt-4 space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (likedTargets.length === 0) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t('No liked posts yet')}
</div>
)
}
return (
<div className="mt-4 min-w-0">
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing liked posts...')}
</div>
)}
<div className="space-y-2">
{displayedTargets.map(({ target }) => (
<NoteCard key={target.id} className="w-full" event={target} filterMutedNotes={false} bottomNoteLabel={t('Liked by you')} />
))}
</div>
{displayedTargets.length < likedTargets.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
</div>
)}
</div>
)
})
ProfileLikedFeed.displayName = 'ProfileLikedFeed'
export default ProfileLikedFeed

101
src/components/Profile/ProfileMediaFeed.tsx

@ -1,101 +0,0 @@
import NoteList, { type TNoteListRef } from '@/components/NoteList'
import { buildAuthorInboxOutboxRelayUrls } from '@/lib/favorites-feed-relays'
import { PROFILE_MEDIA_TAB_KINDS } from '@/constants'
import { buildProfileMediaSubRequests } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { normalizeHexPubkey } from '@/lib/pubkey'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import { hexPubkeysEqual } from '@/lib/pubkey'
import client from '@/services/client.service'
import { forwardRef, useEffect, useMemo, useState } from 'react'
const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey }, ref) => {
const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays()
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
const pk = pubkey?.trim()
if (!me || !pk) return false
try {
return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pk))
} catch {
return false
}
}, [nostr?.pubkey, pubkey])
const [authorRelayUrls, setAuthorRelayUrls] = useState<string[] | null>(null)
useEffect(() => {
const pk = pubkey?.trim()
if (!pk) {
setAuthorRelayUrls([])
return
}
let cancelled = false
setAuthorRelayUrls(null)
void client
.fetchRelayList(pk)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
.then((authorRl) => {
if (cancelled) return
setAuthorRelayUrls(buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays))
})
return () => {
cancelled = true
}
}, [pubkey, blockedRelays, includeAuthorLocalRelays])
const subRequests = useMemo(() => {
const pk = pubkey?.trim()
if (!pk || !authorRelayUrls?.length) return []
return buildProfileMediaSubRequests(authorRelayUrls, blockedRelays, pk)
}, [pubkey, authorRelayUrls, blockedRelays])
const feedSubscriptionKey = useMemo(() => {
const pk = pubkey?.trim()
if (!pk) return 'profile-media-empty'
return `profile-media-${normalizeHexPubkey(pk)}`
}, [pubkey])
const showKinds = useMemo(() => [...PROFILE_MEDIA_TAB_KINDS], [])
if (authorRelayUrls === null) {
return (
<div className="min-h-[min(40vh,320px)] min-w-0 px-2 py-8 text-center text-sm text-muted-foreground">
{/* Skeleton while author NIP-65 resolves — avoids provisional→refined subRequest churn */}
</div>
)
}
if (!subRequests.length) {
return (
<div className="min-h-[min(40vh,320px)] min-w-0 px-2 py-8 text-center text-sm text-muted-foreground" />
)
}
return (
<div className="min-h-[min(40vh,320px)] min-w-0">
<NoteList
ref={ref}
subRequests={subRequests}
feedSubscriptionKey={feedSubscriptionKey}
hostPrimaryPageName="profile"
showKinds={showKinds}
useFilterAsIs
preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch
revealBatchSize={48}
filterMutedNotes={false}
showKind1OPs
showKind1Replies
showKind1111
hideReplies={false}
timelinePublicReadFallback
/>
</div>
)
})
ProfileMediaFeed.displayName = 'ProfileMediaFeed'
export default ProfileMediaFeed

50
src/components/Profile/ProfilePublicationsFeed.tsx

@ -1,50 +0,0 @@
import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { ExtendedKind } from '@/constants'
import { PROFILE_PUBLICATIONS_TAB_KINDS } from '@/constants'
import { forwardRef, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProfileTimeline from './ProfileTimeline'
const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const kindsList = useMemo(() => [...PROFILE_PUBLICATIONS_TAB_KINDS], [])
const cacheKey = useMemo(() => `${pubkey}-profile-publications-v3`, [pubkey])
const visiblePublicationFilter = useMemo(
() => (event: { kind: number }) => event.kind !== ExtendedKind.PUBLICATION_CONTENT,
[]
)
const getKindLabel = (_kindValue: string) => t('articles and publications')
return (
<div className="mt-4">
<div className="mb-2 flex flex-wrap items-center gap-2 px-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search articles...')}
className="w-64 max-w-full"
/>
</div>
<ProfileTimeline
ref={ref}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter="all"
kinds={kindsList}
cacheKey={cacheKey}
filterPredicate={visiblePublicationFilter}
getKindLabel={getKindLabel}
refreshLabel={t('Refreshing articles...')}
emptyLabel={t('No articles or publications found')}
emptySearchLabel={t('No articles or publications match your search')}
/>
</div>
)
})
ProfilePublicationsFeed.displayName = 'ProfilePublicationsFeed'
export default ProfilePublicationsFeed

68
src/components/Profile/ProfileReportsFeed.tsx → src/components/Profile/ProfileReportsDialog.tsx

@ -1,15 +1,22 @@
import ReportCard from '@/components/ReportCard' import ReportCard from '@/components/ReportCard'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useProfileReportsEvents } from '@/hooks/useProfileReportsEvents' import { useProfileReportsEvents } from '@/hooks/useProfileReportsEvents'
import { useProfileReportsRelayBuilder } from '@/hooks/useProfileReportsRelayBuilder' import { useProfileReportsRelayBuilder } from '@/hooks/useProfileReportsRelayBuilder'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { export function ProfileReportsPanel({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const relayUrlsBuilder = useProfileReportsRelayBuilder(pubkey) const relayUrlsBuilder = useProfileReportsRelayBuilder(pubkey)
const { received, made, isLoading, refresh } = useProfileReportsEvents({ const { received, made, isLoading } = useProfileReportsEvents({
pubkey, pubkey,
relayUrlsBuilder relayUrlsBuilder
}) })
@ -19,20 +26,9 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
if (!isLoading) setIsRefreshing(false) if (!isLoading) setIsRefreshing(false)
}, [isLoading]) }, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
}
}),
[refresh]
)
if (isLoading && received.length === 0 && made.length === 0) { if (isLoading && received.length === 0 && made.length === 0) {
return ( return (
<div className="mt-4 space-y-4"> <div className="space-y-4 py-2">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" /> <Skeleton key={i} className="h-32 w-full" />
))} ))}
@ -41,10 +37,10 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
} }
return ( return (
<div className="mt-4 space-y-8"> <div className="space-y-8">
{isRefreshing && ( {isRefreshing && (
<div <div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500" className="flex items-center justify-center gap-2 py-2 text-center text-sm text-green-500"
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
@ -56,12 +52,12 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
<section className="space-y-2" aria-labelledby="profile-reports-received-heading"> <section className="space-y-2" aria-labelledby="profile-reports-received-heading">
<h2 <h2
id="profile-reports-received-heading" id="profile-reports-received-heading"
className="px-4 text-sm font-semibold text-foreground" className="text-sm font-semibold text-foreground"
> >
{t('Reports received')} {t('Reports received')}
</h2> </h2>
{received.length === 0 ? ( {received.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('No reports received')}</p> <p className="py-2 text-sm text-muted-foreground">{t('No reports received')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{received.map((event) => ( {received.map((event) => (
@ -72,11 +68,11 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
</section> </section>
<section className="space-y-2" aria-labelledby="profile-reports-made-heading"> <section className="space-y-2" aria-labelledby="profile-reports-made-heading">
<h2 id="profile-reports-made-heading" className="px-4 text-sm font-semibold text-foreground"> <h2 id="profile-reports-made-heading" className="text-sm font-semibold text-foreground">
{t('Reports made')} {t('Reports made')}
</h2> </h2>
{made.length === 0 ? ( {made.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('No reports made')}</p> <p className="py-2 text-sm text-muted-foreground">{t('No reports made')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{made.map((event) => ( {made.map((event) => (
@ -87,8 +83,30 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
</section> </section>
</div> </div>
) )
}) }
ProfileReportsFeed.displayName = 'ProfileReportsFeed' export default function ProfileReportsDialog({
open,
onOpenChange,
pubkey
}: {
open: boolean
onOpenChange: (open: boolean) => void
pubkey: string
}) {
const { t } = useTranslation()
export default ProfileReportsFeed return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(90vh,720px)] max-w-lg flex-col overflow-hidden sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t('Reports')}</DialogTitle>
<DialogDescription>{t('Profile reports dialog description')}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<ProfileReportsPanel pubkey={pubkey} />
</div>
</DialogContent>
</Dialog>
)
}

117
src/components/Profile/ProfileWallFeed.tsx

@ -1,117 +0,0 @@
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useProfileWall } from '@/hooks/useProfileWall'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
type ProfileWallFeedProps = {
pubkey: string
profileEventId?: string
}
const ProfileWallFeed = forwardRef<{ refresh: () => void }, ProfileWallFeedProps>(
({ pubkey, profileEventId }, ref) => {
const { t } = useTranslation()
const { badges, comments, isLoading, refresh } = useProfileWall(pubkey, profileEventId)
const [isRefreshing, setIsRefreshing] = useState(false)
useEffect(() => {
if (!isLoading) setIsRefreshing(false)
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
}
}),
[refresh]
)
if (isLoading && badges.length === 0 && comments.length === 0) {
return (
<div className="mt-4 space-y-6 px-4">
<div className="flex gap-3">
<Skeleton className="h-24 w-24 rounded-full md:h-48 md:w-48" />
<Skeleton className="h-24 w-24 rounded-full md:h-48 md:w-48" />
</div>
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
return (
<div className="mt-4 space-y-8">
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing wall...')}
</div>
)}
{badges.length > 0 && (
<section className="px-4" aria-label={t('Badges')}>
<div className="flex flex-wrap gap-3 justify-center sm:justify-start">
{badges.map((badge) => (
<div
key={`${badge.definitionCoordinate}:${badge.awardEventId}`}
className="flex flex-col items-center gap-1"
title={badge.description ?? badge.name}
>
{badge.imageUrl ? (
<img
src={badge.imageUrl}
alt={badge.name}
className="h-24 w-24 rounded-lg object-cover md:h-48 md:w-48"
loading="lazy"
/>
) : (
<div
className="flex h-24 w-24 items-center justify-center rounded-lg border border-border bg-muted px-2 text-center text-xs font-medium md:h-48 md:w-48 md:text-sm"
aria-hidden
>
{badge.name}
</div>
)}
<span className="max-w-[6rem] truncate text-center text-xs text-muted-foreground md:max-w-[12rem]">
{badge.name}
</span>
</div>
))}
</div>
</section>
)}
<section className="space-y-2" aria-labelledby="profile-wall-comments-heading">
<h2 id="profile-wall-comments-heading" className="px-4 text-sm font-semibold text-foreground">
{t('Wall')}
</h2>
{!profileEventId ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('Profile metadata not loaded yet')}</p>
) : comments.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('No wall comments yet')}</p>
) : (
<div className="space-y-2">
{comments.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
)}
</section>
</div>
)
}
)
ProfileWallFeed.displayName = 'ProfileWallFeed'
export default ProfileWallFeed

149
src/components/Profile/index.tsx

@ -37,6 +37,7 @@ import {
Ellipsis, Ellipsis,
ExternalLink, ExternalLink,
Calendar, Calendar,
Flag,
MapPin, MapPin,
Pencil, Pencil,
SatelliteDish, SatelliteDish,
@ -62,14 +63,9 @@ import logger from '@/lib/logger'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import NotFound from '../NotFound' import NotFound from '../NotFound'
import FollowedBy from './FollowedBy' import FollowedBy from './FollowedBy'
import ProfileFeedWithPins from './ProfileFeedWithPins' import ProfileBadges from './ProfileBadges'
import ProfileLikedFeed from './ProfileLikedFeed' import ProfileFeed from './ProfileFeed'
import ProfileMediaFeed from './ProfileMediaFeed' import ProfileReportsDialog from './ProfileReportsDialog'
import ProfilePublicationsFeed from './ProfilePublicationsFeed'
import ProfileReportsFeed from './ProfileReportsFeed'
import ProfileWallFeed from './ProfileWallFeed'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { TNoteListRef } from '@/components/NoteList'
import SmartFollowings from './SmartFollowings' import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink' import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays' import SmartRelays from './SmartRelays'
@ -101,7 +97,7 @@ export default function Profile({
alexandriaNotFoundHref = null alexandriaNotFoundHref = null
}: { }: {
id?: string id?: string
/** When set, exposes {@link ProfileFeedWithPins} `refresh` for titlebars / parent pages. */ /** When set, exposes {@link ProfileFeed} `refresh` for titlebars / parent pages. */
feedRef?: Ref<{ refresh: () => void }> feedRef?: Ref<{ refresh: () => void }>
/** When profile lookup fails, link to Alexandria with the same identifier (search / deep link). */ /** When profile lookup fails, link to Alexandria with the same identifier (search / deep link). */
alexandriaNotFoundHref?: string | null alexandriaNotFoundHref?: string | null
@ -111,17 +107,8 @@ export default function Profile({
const { navigate: navigatePrimary } = usePrimaryPage() const { navigate: navigatePrimary } = usePrimaryPage()
const internalFeedRef = useRef<{ refresh: () => void }>(null) const internalFeedRef = useRef<{ refresh: () => void }>(null)
const profileFeedRef = feedRef ?? internalFeedRef const profileFeedRef = feedRef ?? internalFeedRef
const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef<TNoteListRef>(null)
const publicationsFeedRef = useRef<{ refresh: () => void }>(null)
const reportsFeedRef = useRef<{ refresh: () => void }>(null)
const wallFeedRef = useRef<{ refresh: () => void }>(null)
const likedFeedRef = useRef<{ refresh: () => void }>(null)
const [profileFeedTab, setProfileFeedTab] = useState<
'posts' | 'media' | 'publications' | 'reports' | 'wall' | 'liked'
>('posts')
const profilePubkeyRef = useRef<string | null>(null) const profilePubkeyRef = useRef<string | null>(null)
const pendingReportsRefreshRef = useRef(false) const [openReportsDialog, setOpenReportsDialog] = useState(false)
const { profile, isFetching } = useFetchProfile(id) const { profile, isFetching } = useFetchProfile(id)
profilePubkeyRef.current = profile?.pubkey ?? null profilePubkeyRef.current = profile?.pubkey ?? null
@ -309,17 +296,8 @@ export default function Profile({
const m = r as MutableRefObject<{ refresh: () => void } | null> const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = { m.current = {
refresh: () => { refresh: () => {
postsFeedRef.current?.refresh() internalFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
publicationsFeedRef.current?.refresh()
wallFeedRef.current?.refresh()
likedFeedRef.current?.refresh()
const pk = profilePubkeyRef.current const pk = profilePubkeyRef.current
if (reportsFeedRef.current) {
reportsFeedRef.current.refresh()
} else {
pendingReportsRefreshRef.current = true
}
if (pk) { if (pk) {
void refreshAuthorReplaceables(pk) void refreshAuthorReplaceables(pk)
} }
@ -330,39 +308,6 @@ export default function Profile({
} }
}, [refreshAuthorReplaceables]) }, [refreshAuthorReplaceables])
useEffect(() => {
if (!profile?.pubkey) return
setProfileFeedTab('posts')
}, [profile?.pubkey])
useEffect(() => {
if (!isSelf && profileFeedTab === 'liked') {
setProfileFeedTab('posts')
}
}, [isSelf, profileFeedTab])
/**
* Radix {@link TabsContent} unmounts inactive panels, so media / publications / liked feeds can miss the same
* warm-up window as Posts or show a frozen first paint. Re-run their refresh path when the tab becomes active
* (after refs attach {@link useLayoutEffect}).
*/
useLayoutEffect(() => {
if (profileFeedTab === 'media') {
mediaFeedRef.current?.refresh()
} else if (profileFeedTab === 'publications') {
publicationsFeedRef.current?.refresh()
} else if (profileFeedTab === 'reports') {
if (pendingReportsRefreshRef.current) {
pendingReportsRefreshRef.current = false
}
reportsFeedRef.current?.refresh()
} else if (profileFeedTab === 'wall') {
wallFeedRef.current?.refresh()
} else if (profileFeedTab === 'liked') {
likedFeedRef.current?.refresh()
}
}, [profileFeedTab])
if (!profile && isFetching) { if (!profile && isFetching) {
return ( return (
<> <>
@ -452,13 +397,14 @@ export default function Profile({
<div className="flex flex-wrap justify-end gap-2 items-center min-w-0"> <div className="flex flex-wrap justify-end gap-2 items-center min-w-0">
<ProfileOptions <ProfileOptions
pubkey={pubkey} pubkey={pubkey}
profileEvent={profileEvent} profileEvent={effectiveProfileEvent}
onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined} onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined}
onSendCallInvite={ onSendCallInvite={
!isSelf !isSelf
? (url) => setOpenCallInviteTo({ pubkey, url }) ? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined : undefined
} }
onSeeReports={() => setOpenReportsDialog(true)}
/> />
{isSelf ? ( {isSelf ? (
<DropdownMenu> <DropdownMenu>
@ -517,6 +463,10 @@ export default function Profile({
<Network /> <Network />
{t('Interactions map')} {t('Interactions map')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setOpenReportsDialog(true)}>
<Flag />
{t('See reports')}
</DropdownMenuItem>
{nostrArchivesProfileUrl ? ( {nostrArchivesProfileUrl ? (
<DropdownMenuItem onClick={() => openExternalUrl(nostrArchivesProfileUrl)}> <DropdownMenuItem onClick={() => openExternalUrl(nostrArchivesProfileUrl)}>
<ExternalLink /> <ExternalLink />
@ -666,75 +616,16 @@ export default function Profile({
</div> </div>
{!isSelf && <FollowedBy pubkey={pubkey} />} {!isSelf && <FollowedBy pubkey={pubkey} />}
</div> </div>
<ProfileBadges pubkey={pubkey} profileEventId={effectiveProfileEvent?.id} />
</div> </div>
</div> </div>
</div> </div>
<Tabs <ProfileFeed ref={profileFeedRef} pubkey={pubkey} />
value={profileFeedTab} <ProfileReportsDialog
onValueChange={(v) => { open={openReportsDialog}
if ( onOpenChange={setOpenReportsDialog}
v === 'posts' || pubkey={pubkey}
v === 'media' || />
v === 'publications' ||
v === 'reports' ||
v === 'wall' ||
(isSelf && v === 'liked')
) {
setProfileFeedTab(v)
}
}}
className="min-w-0 pt-4"
>
<TabsList className="mb-2 ml-1 h-auto min-h-9 w-full max-w-full justify-start flex-wrap gap-1 md:ml-4">
<TabsTrigger value="posts" className="shrink-0">
{t('Posts')}
</TabsTrigger>
<TabsTrigger value="media" className="shrink-0">
{t('Media')}
</TabsTrigger>
<TabsTrigger
value="publications"
className="shrink whitespace-normal text-center leading-tight max-sm:px-2 max-sm:text-xs"
>
{t('Articles and Publications')}
</TabsTrigger>
<TabsTrigger value="reports" className="shrink-0">
{t('Reports')}
</TabsTrigger>
<TabsTrigger value="wall" className="shrink-0">
{t('Wall')}
</TabsTrigger>
{isSelf && (
<TabsTrigger value="liked" className="shrink-0">
{t('Liked')}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="posts" className="min-w-0 focus-visible:outline-none">
<ProfileFeedWithPins ref={postsFeedRef} pubkey={pubkey} />
</TabsContent>
<TabsContent value="media" className="min-w-0 focus-visible:outline-none">
<ProfileMediaFeed ref={mediaFeedRef} pubkey={pubkey} />
</TabsContent>
<TabsContent value="publications" className="min-w-0 focus-visible:outline-none">
<ProfilePublicationsFeed ref={publicationsFeedRef} pubkey={pubkey} />
</TabsContent>
<TabsContent value="reports" className="min-w-0 focus-visible:outline-none">
{profileFeedTab === 'reports' ? (
<ProfileReportsFeed ref={reportsFeedRef} pubkey={pubkey} />
) : null}
</TabsContent>
<TabsContent value="wall" className="min-w-0 focus-visible:outline-none">
{profileFeedTab === 'wall' ? (
<ProfileWallFeed ref={wallFeedRef} pubkey={pubkey} profileEventId={profileEvent?.id} />
) : null}
</TabsContent>
{isSelf && (
<TabsContent value="liked" className="min-w-0 focus-visible:outline-none">
<ProfileLikedFeed ref={likedFeedRef} pubkey={pubkey} />
</TabsContent>
)}
</Tabs>
{openPublicMessageTo && ( {openPublicMessageTo && (
<PostEditor <PostEditor
open={!!openPublicMessageTo} open={!!openPublicMessageTo}

12
src/components/ProfileOptions/index.tsx

@ -28,6 +28,7 @@ import {
Copy, Copy,
Ellipsis, Ellipsis,
ExternalLink, ExternalLink,
Flag,
ThumbsUp, ThumbsUp,
MessageCircle, MessageCircle,
Network, Network,
@ -47,7 +48,8 @@ export default function ProfileOptions({
pubkey, pubkey,
profileEvent, profileEvent,
onSendPublicMessage, onSendPublicMessage,
onSendCallInvite onSendCallInvite,
onSeeReports
}: { }: {
pubkey: string pubkey: string
/** Optional profile event (kind 0): reply / like, republish to relays, view JSON */ /** Optional profile event (kind 0): reply / like, republish to relays, view JSON */
@ -56,6 +58,8 @@ export default function ProfileOptions({
onSendPublicMessage?: () => void onSendPublicMessage?: () => void
/** Opens the post editor to send the call invite URL as a public message to this profile. */ /** Opens the post editor to send the call invite URL as a public message to this profile. */
onSendCallInvite?: (url: string) => void onSendCallInvite?: (url: string) => void
/** Opens the profile reports modal. */
onSeeReports?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
@ -257,6 +261,12 @@ export default function ProfileOptions({
<Network /> <Network />
{t('Interactions map')} {t('Interactions map')}
</DropdownMenuItem> </DropdownMenuItem>
{onSeeReports && (
<DropdownMenuItem onClick={onSeeReports}>
<Flag />
{t('See reports')}
</DropdownMenuItem>
)}
{nostrArchivesProfileUrl && ( {nostrArchivesProfileUrl && (
<DropdownMenuItem onClick={() => openExternalUrl(nostrArchivesProfileUrl)}> <DropdownMenuItem onClick={() => openExternalUrl(nostrArchivesProfileUrl)}>
<ExternalLink /> <ExternalLink />

3
src/constants.ts

@ -920,6 +920,9 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter(
k !== ExtendedKind.APPLICATION_HANDLER_INFO k !== ExtendedKind.APPLICATION_HANDLER_INFO
) )
/** REQ `limit` for profile page timelines (single feed; narrow with kind filter or 🔍 search). */
export const PROFILE_TIMELINE_REQ_LIMIT = 500
/** Long-form, wiki, and publication index events for the profile "Articles and Publications" tab. */ /** Long-form, wiki, and publication index events for the profile "Articles and Publications" tab. */
export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [ export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [
kinds.LongFormArticle, kinds.LongFormArticle,

2
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -118,7 +118,7 @@ export function useProfileAuthorFeedSubRequests({
const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], []) const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], [])
const feedSubscriptionKey = useMemo(() => { const feedSubscriptionKey = useMemo(() => {
return `profile-posts-${authorHex}-${kindsKey}-${limit}` return `profile-feed-${authorHex}-${kindsKey}-${limit}`
}, [authorHex, kindsKey, limit]) }, [authorHex, kindsKey, limit])
const refresh = useCallback(() => { const refresh = useCallback(() => {

2
src/i18n/locales/en.ts

@ -131,6 +131,8 @@ export default {
"Open in wallet": "Open in wallet", "Open in wallet": "Open in wallet",
"Open in {{name}}": "Open in {{name}}", "Open in {{name}}": "Open in {{name}}",
"Open with": "Open with", "Open with": "Open with",
"See reports": "See reports",
"Profile reports dialog description": "Reports received by and submitted from this profile.",
"Raw profile event": "Raw profile event", "Raw profile event": "Raw profile event",
"Full profile event": "Full profile event", "Full profile event": "Full profile event",
"Event (JSON)": "Event (JSON)", "Event (JSON)": "Event (JSON)",

2
src/lib/profile-author-warmup-spec.test.ts

@ -27,8 +27,8 @@ describe('getProfileAuthorWarmupSpec', () => {
}) })
it('detects profile feed subscription keys', () => { it('detects profile feed subscription keys', () => {
expect(isProfileTimelineSubscriptionKey('profile-feed-abc-1-500')).toBe(true)
expect(isProfileTimelineSubscriptionKey('profile-posts-abc-1-200')).toBe(true) expect(isProfileTimelineSubscriptionKey('profile-posts-abc-1-200')).toBe(true)
expect(isProfileTimelineSubscriptionKey('profile-media-abc')).toBe(true)
expect(isProfileTimelineSubscriptionKey('home-all-favorites')).toBe(false) expect(isProfileTimelineSubscriptionKey('home-all-favorites')).toBe(false)
expect(isProfileTimelineSubscriptionKey(null)).toBe(false) expect(isProfileTimelineSubscriptionKey(null)).toBe(false)
}) })

Loading…
Cancel
Save