From f4d2480328173150603c67516e0461f6f80c7775 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 20 May 2026 14:39:22 +0200 Subject: [PATCH] revamp profile feeds --- src/components/Profile/ProfileBadges.tsx | 56 ++++ ...rofileFeedWithPins.tsx => ProfileFeed.tsx} | 16 +- src/components/Profile/ProfileLikedFeed.tsx | 298 ------------------ src/components/Profile/ProfileMediaFeed.tsx | 101 ------ .../Profile/ProfilePublicationsFeed.tsx | 50 --- ...portsFeed.tsx => ProfileReportsDialog.tsx} | 68 ++-- src/components/Profile/ProfileWallFeed.tsx | 117 ------- src/components/Profile/index.tsx | 149 ++------- src/components/ProfileOptions/index.tsx | 12 +- src/constants.ts | 3 + src/hooks/useProfileAuthorFeedSubRequests.ts | 2 +- src/i18n/locales/en.ts | 2 + src/lib/profile-author-warmup-spec.test.ts | 2 +- 13 files changed, 145 insertions(+), 731 deletions(-) create mode 100644 src/components/Profile/ProfileBadges.tsx rename src/components/Profile/{ProfileFeedWithPins.tsx => ProfileFeed.tsx} (93%) delete mode 100644 src/components/Profile/ProfileLikedFeed.tsx delete mode 100644 src/components/Profile/ProfileMediaFeed.tsx delete mode 100644 src/components/Profile/ProfilePublicationsFeed.tsx rename src/components/Profile/{ProfileReportsFeed.tsx => ProfileReportsDialog.tsx} (56%) delete mode 100644 src/components/Profile/ProfileWallFeed.tsx diff --git a/src/components/Profile/ProfileBadges.tsx b/src/components/Profile/ProfileBadges.tsx new file mode 100644 index 00000000..d2424a18 --- /dev/null +++ b/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 ( +
+ + +
+ ) + } + + if (badges.length === 0) return null + + return ( +
+
+ {badges.map((badge) => ( +
+ {badge.imageUrl ? ( + {badge.name} + ) : ( +
+ {badge.name} +
+ )} + {badge.name} +
+ ))} +
+
+ ) +} diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeed.tsx similarity index 93% rename from src/components/Profile/ProfileFeedWithPins.tsx rename to src/components/Profile/ProfileFeed.tsx index a971e2ef..0f8d0fe2 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -3,7 +3,7 @@ import NoteCard from '@/components/NoteCard' import KindFilter from '@/components/KindFilter' import { RefreshButton } from '@/components/RefreshButton' 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 { useProfilePins } from '@/hooks/useProfilePins' 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 { 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 { isEventDeleted } = useDeletedEvent() const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = @@ -32,13 +34,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) - const postsTabKinds = useMemo(() => [...PROFILE_POSTS_TAB_KINDS], []) - const { subRequests, followingFeedDeltaSubRequests, feedSubscriptionKey, refresh: refreshAuthorRelayLayers } = useProfileAuthorFeedSubRequests({ pubkey, - kinds: postsTabKinds, - limit: 200 + kinds: profileFeedKinds, + limit: PROFILE_TIMELINE_REQ_LIMIT }) 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 diff --git a/src/components/Profile/ProfileLikedFeed.tsx b/src/components/Profile/ProfileLikedFeed.tsx deleted file mode 100644 index a4ad7c8e..00000000 --- a/src/components/Profile/ProfileLikedFeed.tsx +++ /dev/null @@ -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() - 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([]) - const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) - const bottomRef = useRef(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() - 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() - 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 ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (likedTargets.length === 0) { - return ( -
- {t('No liked posts yet')} -
- ) - } - - return ( -
- {isRefreshing && ( -
- - {t('Refreshing liked posts...')} -
- )} -
- {displayedTargets.map(({ target }) => ( - - ))} -
- {displayedTargets.length < likedTargets.length && ( -
-
{t('Loading more...')}
-
- )} -
- ) -}) - -ProfileLikedFeed.displayName = 'ProfileLikedFeed' - -export default ProfileLikedFeed diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx deleted file mode 100644 index d0996cfb..00000000 --- a/src/components/Profile/ProfileMediaFeed.tsx +++ /dev/null @@ -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(({ 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(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 ( -
- {/* Skeleton while author NIP-65 resolves — avoids provisional→refined subRequest churn */} -
- ) - } - - if (!subRequests.length) { - return ( -
- ) - } - - return ( -
- -
- ) -}) - -ProfileMediaFeed.displayName = 'ProfileMediaFeed' - -export default ProfileMediaFeed diff --git a/src/components/Profile/ProfilePublicationsFeed.tsx b/src/components/Profile/ProfilePublicationsFeed.tsx deleted file mode 100644 index 9c3c2786..00000000 --- a/src/components/Profile/ProfilePublicationsFeed.tsx +++ /dev/null @@ -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 ( -
-
- -
- -
- ) -}) - -ProfilePublicationsFeed.displayName = 'ProfilePublicationsFeed' - -export default ProfilePublicationsFeed diff --git a/src/components/Profile/ProfileReportsFeed.tsx b/src/components/Profile/ProfileReportsDialog.tsx similarity index 56% rename from src/components/Profile/ProfileReportsFeed.tsx rename to src/components/Profile/ProfileReportsDialog.tsx index 662068a2..97cb7ab5 100644 --- a/src/components/Profile/ProfileReportsFeed.tsx +++ b/src/components/Profile/ProfileReportsDialog.tsx @@ -1,15 +1,22 @@ import ReportCard from '@/components/ReportCard' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { useProfileReportsEvents } from '@/hooks/useProfileReportsEvents' import { useProfileReportsRelayBuilder } from '@/hooks/useProfileReportsRelayBuilder' -import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' -import { useTranslation } from 'react-i18next' 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 relayUrlsBuilder = useProfileReportsRelayBuilder(pubkey) - const { received, made, isLoading, refresh } = useProfileReportsEvents({ + const { received, made, isLoading } = useProfileReportsEvents({ pubkey, relayUrlsBuilder }) @@ -19,20 +26,9 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string if (!isLoading) setIsRefreshing(false) }, [isLoading]) - useImperativeHandle( - ref, - () => ({ - refresh: () => { - setIsRefreshing(true) - refresh() - } - }), - [refresh] - ) - if (isLoading && received.length === 0 && made.length === 0) { return ( -
+
{Array.from({ length: 3 }).map((_, i) => ( ))} @@ -41,10 +37,10 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string } return ( -
+
{isRefreshing && (
@@ -56,12 +52,12 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string

{t('Reports received')}

{received.length === 0 ? ( -

{t('No reports received')}

+

{t('No reports received')}

) : (
{received.map((event) => ( @@ -72,11 +68,11 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
-

+

{t('Reports made')}

{made.length === 0 ? ( -

{t('No reports made')}

+

{t('No reports made')}

) : (
{made.map((event) => ( @@ -87,8 +83,30 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
) -}) +} -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 ( + + + + {t('Reports')} + {t('Profile reports dialog description')} + +
+ +
+
+
+ ) +} diff --git a/src/components/Profile/ProfileWallFeed.tsx b/src/components/Profile/ProfileWallFeed.tsx deleted file mode 100644 index 742c74da..00000000 --- a/src/components/Profile/ProfileWallFeed.tsx +++ /dev/null @@ -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 ( -
-
- - -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) - } - - return ( -
- {isRefreshing && ( -
- - {t('Refreshing wall...')} -
- )} - - {badges.length > 0 && ( -
-
- {badges.map((badge) => ( -
- {badge.imageUrl ? ( - {badge.name} - ) : ( -
- {badge.name} -
- )} - - {badge.name} - -
- ))} -
-
- )} - -
-

- {t('Wall')} -

- {!profileEventId ? ( -

{t('Profile metadata not loaded yet')}

- ) : comments.length === 0 ? ( -

{t('No wall comments yet')}

- ) : ( -
- {comments.map((event) => ( - - ))} -
- )} -
-
- ) - } -) - -ProfileWallFeed.displayName = 'ProfileWallFeed' - -export default ProfileWallFeed diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 59657454..1b6ca986 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -37,6 +37,7 @@ import { Ellipsis, ExternalLink, Calendar, + Flag, MapPin, Pencil, SatelliteDish, @@ -62,14 +63,9 @@ import logger from '@/lib/logger' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' -import ProfileFeedWithPins from './ProfileFeedWithPins' -import ProfileLikedFeed from './ProfileLikedFeed' -import ProfileMediaFeed from './ProfileMediaFeed' -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 ProfileBadges from './ProfileBadges' +import ProfileFeed from './ProfileFeed' +import ProfileReportsDialog from './ProfileReportsDialog' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' @@ -101,7 +97,7 @@ export default function Profile({ alexandriaNotFoundHref = null }: { 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 }> /** When profile lookup fails, link to Alexandria with the same identifier (search / deep link). */ alexandriaNotFoundHref?: string | null @@ -111,17 +107,8 @@ export default function Profile({ const { navigate: navigatePrimary } = usePrimaryPage() const internalFeedRef = useRef<{ refresh: () => void }>(null) const profileFeedRef = feedRef ?? internalFeedRef - const postsFeedRef = useRef<{ refresh: () => void }>(null) - const mediaFeedRef = useRef(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(null) - const pendingReportsRefreshRef = useRef(false) + const [openReportsDialog, setOpenReportsDialog] = useState(false) const { profile, isFetching } = useFetchProfile(id) profilePubkeyRef.current = profile?.pubkey ?? null @@ -309,17 +296,8 @@ export default function Profile({ const m = r as MutableRefObject<{ refresh: () => void } | null> m.current = { refresh: () => { - postsFeedRef.current?.refresh() - mediaFeedRef.current?.refresh() - publicationsFeedRef.current?.refresh() - wallFeedRef.current?.refresh() - likedFeedRef.current?.refresh() + internalFeedRef.current?.refresh() const pk = profilePubkeyRef.current - if (reportsFeedRef.current) { - reportsFeedRef.current.refresh() - } else { - pendingReportsRefreshRef.current = true - } if (pk) { void refreshAuthorReplaceables(pk) } @@ -330,39 +308,6 @@ export default function Profile({ } }, [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) { return ( <> @@ -452,13 +397,14 @@ export default function Profile({
setOpenPublicMessageTo(pubkey) : undefined} onSendCallInvite={ !isSelf ? (url) => setOpenCallInviteTo({ pubkey, url }) : undefined } + onSeeReports={() => setOpenReportsDialog(true)} /> {isSelf ? ( @@ -517,6 +463,10 @@ export default function Profile({ {t('Interactions map')} + setOpenReportsDialog(true)}> + + {t('See reports')} + {nostrArchivesProfileUrl ? ( openExternalUrl(nostrArchivesProfileUrl)}> @@ -666,75 +616,16 @@ export default function Profile({
{!isSelf && }
+
- { - if ( - v === 'posts' || - v === 'media' || - v === 'publications' || - v === 'reports' || - v === 'wall' || - (isSelf && v === 'liked') - ) { - setProfileFeedTab(v) - } - }} - className="min-w-0 pt-4" - > - - - {t('Posts')} - - - {t('Media')} - - - {t('Articles and Publications')} - - - {t('Reports')} - - - {t('Wall')} - - {isSelf && ( - - {t('Liked')} - - )} - - - - - - - - - - - - {profileFeedTab === 'reports' ? ( - - ) : null} - - - {profileFeedTab === 'wall' ? ( - - ) : null} - - {isSelf && ( - - - - )} - + + {openPublicMessageTo && ( void /** Opens the post editor to send the call invite URL as a public message to this profile. */ onSendCallInvite?: (url: string) => void + /** Opens the profile reports modal. */ + onSeeReports?: () => void }) { const { t } = useTranslation() const { navigate } = usePrimaryPage() @@ -257,6 +261,12 @@ export default function ProfileOptions({ {t('Interactions map')} + {onSeeReports && ( + + + {t('See reports')} + + )} {nostrArchivesProfileUrl && ( openExternalUrl(nostrArchivesProfileUrl)}> diff --git a/src/constants.ts b/src/constants.ts index fbdc42e9..e1095a6e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -920,6 +920,9 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( 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. */ export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [ kinds.LongFormArticle, diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts index 2645133e..3c71408a 100644 --- a/src/hooks/useProfileAuthorFeedSubRequests.ts +++ b/src/hooks/useProfileAuthorFeedSubRequests.ts @@ -118,7 +118,7 @@ export function useProfileAuthorFeedSubRequests({ const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], []) const feedSubscriptionKey = useMemo(() => { - return `profile-posts-${authorHex}-${kindsKey}-${limit}` + return `profile-feed-${authorHex}-${kindsKey}-${limit}` }, [authorHex, kindsKey, limit]) const refresh = useCallback(() => { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e8e17bc1..cfee8924 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -131,6 +131,8 @@ export default { "Open in wallet": "Open in wallet", "Open in {{name}}": "Open in {{name}}", "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", "Full profile event": "Full profile event", "Event (JSON)": "Event (JSON)", diff --git a/src/lib/profile-author-warmup-spec.test.ts b/src/lib/profile-author-warmup-spec.test.ts index 677dcdb5..ba325874 100644 --- a/src/lib/profile-author-warmup-spec.test.ts +++ b/src/lib/profile-author-warmup-spec.test.ts @@ -27,8 +27,8 @@ describe('getProfileAuthorWarmupSpec', () => { }) 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-media-abc')).toBe(true) expect(isProfileTimelineSubscriptionKey('home-all-favorites')).toBe(false) expect(isProfileTimelineSubscriptionKey(null)).toBe(false) })