From 121991f3e5492501976f79cd97c10cf4e2d199d9 Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 21 May 2026 23:50:35 -0500 Subject: [PATCH] Drop Media/Badges/Likes tabs, redesign profile tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs: - Activity, Campaigns, Pledges, Posts. That's it. - Media / Badges / Likes removed along with their renderers, ProfileBadgesTab function (152 LoC), useProfileMedia + useProfileLikes hooks, mediaEvents / likedItems / mediaFeedItems / likedFeedItems memos, the sidebarMediaUrl state and its callbacks, and the profile-media / profile-likes-infinite cache invalidations. - The 'isCoreProfileTab' fallthrough now only handles posts/replies. Visuals: - New ProfileTabs component replaces the global SubHeaderBar + TabButton pair on this page. It's a clean column-local sticky bar with backdrop-blurred translucent background, hairline bottom border, and an animated underline that slides between active tabs. No arc decoration, no hover-slice tracking — just tabs. - Sticks to top-mobile-bar on small screens (clears mobile chrome) and top-0 from sidebar breakpoint up. Active tab auto-scrolls into view when it overflows the column. Net: -289 LoC in ProfilePage.tsx; +118 LoC for the new ProfileTabs. --- src/components/profile/ProfileTabs.tsx | 130 ++++++++++ src/pages/ProfilePage.tsx | 341 ++----------------------- 2 files changed, 156 insertions(+), 315 deletions(-) create mode 100644 src/components/profile/ProfileTabs.tsx diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx new file mode 100644 index 00000000..040519da --- /dev/null +++ b/src/components/profile/ProfileTabs.tsx @@ -0,0 +1,130 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; + +export interface ProfileTabsProps { + tabs: Array<{ id: string; label: string }>; + activeTab: string; + onChange: (id: string) => void; +} + +/** + * Profile-local tab bar. + * + * A focused alternative to the global `SubHeaderBar` — no arc decoration, + * no hover slice tracking, no FAB-aware spacing. Just a clean horizontal + * row with an animated underline marking the active tab. Designed for the + * 4-tab profile case (Activity / Campaigns / Pledges / Posts). + * + * Behavior: + * - Sticks to the top of its containing scroll context. The parent column + * can place it inside any scroll region; the bar uses `position: sticky`. + * - Underline animates between active tabs via a single absolute-positioned + * indicator measured from the active tab's offset/width. + * - Horizontally scrolls when overflowing; auto-scrolls the active tab into + * view on selection (matches the previous TabButton behavior). + */ +export function ProfileTabs({ tabs, activeTab, onChange }: ProfileTabsProps) { + const trackRef = useRef(null); + const tabRefs = useRef>(new Map()); + const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null); + + // Measure the active tab and update the underline indicator. + useLayoutEffect(() => { + const btn = tabRefs.current.get(activeTab); + if (!btn) { + setIndicator(null); + return; + } + setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth }); + }, [activeTab, tabs]); + + // Recompute on resize (label-width changes between breakpoints). + useEffect(() => { + const onResize = () => { + const btn = tabRefs.current.get(activeTab); + if (!btn) return; + setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth }); + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [activeTab]); + + // Scroll the active tab into view when activated (overflow scroll case). + useLayoutEffect(() => { + const btn = tabRefs.current.get(activeTab); + const track = trackRef.current; + if (!btn || !track) return; + const left = btn.offsetLeft; + const right = left + btn.offsetWidth; + const viewLeft = track.scrollLeft; + const viewRight = viewLeft + track.clientWidth; + if (left < viewLeft) { + track.scrollTo({ left: left - 8, behavior: 'smooth' }); + } else if (right > viewRight) { + track.scrollTo({ left: right - track.clientWidth + 8, behavior: 'smooth' }); + } + }, [activeTab]); + + return ( +
+
+ {tabs.map((tab) => { + const active = tab.id === activeTab; + return ( + + ); + })} + + {/* Active-tab underline indicator. Animates between tab positions. */} + {indicator && ( + + )} +
+
+ ); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 18485b2c..a5704962 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -28,10 +28,8 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; -import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed'; +import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed'; import type { ProfileTab as CoreProfileTab } from '@/hooks/useProfileFeed'; -import { useProfileMedia } from '@/hooks/useProfileMedia'; -import { MediaCollage, MediaCollageSkeleton } from '@/components/MediaCollage'; import { useProfileSupplementary } from '@/hooks/useProfileData'; import { useNip05Resolve } from '@/hooks/useNip05Resolve'; import { genUserName } from '@/lib/genUserName'; @@ -63,9 +61,8 @@ import { ProfileIdentityRail } from '@/components/profile/ProfileIdentityRail'; import { ProfileCampaignsTab } from '@/components/profile/ProfileCampaignsTab'; import { ProfilePledgesTab } from '@/components/profile/ProfilePledgesTab'; import { ProfileActivityTab } from '@/components/profile/ProfileActivityTab'; +import { ProfileTabs } from '@/components/profile/ProfileTabs'; import type { ParsedCampaign } from '@/lib/campaign'; -import { SubHeaderBar } from '@/components/SubHeaderBar'; -import { TabButton } from '@/components/TabButton'; import type { AddrCoords } from '@/hooks/useEvent'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { impactMedium } from '@/lib/haptics'; @@ -866,13 +863,12 @@ function ProfileBannerImage({ src, onClick }: { src: string; onClick: () => void // ----- Main Component ----- -const DEFAULT_TAB_LABELS = ['Activity', 'Campaigns', 'Pledges', 'Posts', 'Media', 'Badges', 'Likes']; +const DEFAULT_TAB_LABELS = ['Activity', 'Campaigns', 'Pledges', 'Posts']; // Map from display label → internal tab id for core tabs const CORE_TAB_IDS: Record = { 'Activity': 'activity', 'Campaigns': 'campaigns', 'Pledges': 'pledges', 'Posts': 'posts', 'Posts & replies': 'replies', - 'Media': 'media', 'Badges': 'badges', 'Likes': 'likes', }; export function ProfilePage() { @@ -886,7 +882,6 @@ export function ProfilePage() { const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('activity'); - const [sidebarMediaUrl, setSidebarMediaUrl] = useState(null); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [followQROpen, setFollowQROpen] = useState(false); const [followingModalOpen, setFollowingModalOpen] = useState(false); @@ -1089,7 +1084,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe // Keep the active tab in sync if it ever falls out of the recognized set // (e.g. on first mount, or if a user navigates with a stale tab id). useEffect(() => { - const isCoreTab = ['campaigns', 'pledges', 'activity', 'posts', 'replies', 'media', 'badges', 'likes'].includes(activeTab); + const isCoreTab = ['campaigns', 'pledges', 'activity', 'posts', 'replies'].includes(activeTab); if (!isCoreTab) { setActiveTab(firstTabId); } @@ -1107,7 +1102,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe isFetchingNextPage: isFetchingNextFeedPage, } = useProfileFeed( pubkey, - (['posts', 'replies', 'media', 'likes', 'badges'].includes(activeTab) ? activeTab : 'posts') as CoreProfileTab, + (['posts', 'replies'].includes(activeTab) ? activeTab : 'posts') as CoreProfileTab, true, ); @@ -1140,24 +1135,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe description: metadata?.about || 'Nostr profile', }); - // Profile media — NIP-50 `media:true` search via the configured read pool. - const { - data: mediaData, - isPending: mediaPending, - fetchNextPage: fetchNextMediaPage, - hasNextPage: hasNextMediaPage, - isFetchingNextPage: isFetchingNextMediaPage, - } = useProfileMedia(pubkey, true); - - // Infinite-scroll likes - const { - data: likesData, - isPending: likesPending, - fetchNextPage: fetchNextLikesPage, - hasNextPage: hasNextLikesPage, - isFetchingNextPage: isFetchingNextLikesPage, - } = useProfileLikesInfinite(pubkey, activeTab === 'likes'); - // Follow list (cached, for display checks only) const { data: followData } = useFollowList(); @@ -1256,41 +1233,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe return items; }, [feedData?.pages, muteItems]); - // Flatten media pages for the sidebar and media tab - const mediaEvents = useMemo(() => { - if (!mediaData?.pages) return []; - const seen = new Set(); - const events: NostrEvent[] = []; - for (const page of mediaData.pages) { - for (const event of page.events) { - if (!seen.has(event.id)) { - seen.add(event.id); - events.push(event); - } - } - } - return events; - }, [mediaData?.pages]); - - // Profile badges are queried inside ProfileIdentityRail; the page no - // longer needs to fetch them at this level. - - // Flatten likes pages and deduplicate - const likedItems = useMemo(() => { - if (!likesData?.pages) return []; - const seen = new Set(); - const items: NostrEvent[] = []; - for (const page of likesData.pages) { - for (const event of page.events) { - if (!seen.has(event.id)) { - seen.add(event.id); - items.push(event); - } - } - } - return items; - }, [likesData?.pages]); - // Infinite scroll sentinel const { ref: scrollRef, inView } = useInView({ threshold: 0, @@ -1298,45 +1240,21 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe useEffect(() => { if (!inView) return; - if (activeTab === 'likes') { - if (hasNextLikesPage && !isFetchingNextLikesPage) { - fetchNextLikesPage(); - } - } else if (activeTab === 'media') { - if (hasNextMediaPage && !isFetchingNextMediaPage) { - fetchNextMediaPage(); - } - } else { - if (hasNextFeedPage && !isFetchingNextFeedPage) { - fetchNextFeedPage(); - } + if (hasNextFeedPage && !isFetchingNextFeedPage) { + fetchNextFeedPage(); } - }, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage, hasNextMediaPage, isFetchingNextMediaPage, fetchNextMediaPage]); + }, [inView, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage]); const authorEvent = metadataEvent; - // For likes, convert NostrEvents to FeedItems - const likedFeedItems: FeedItem[] = useMemo(() => - likedItems.map(event => ({ event, sortTimestamp: event.created_at })), - [likedItems] - ); - - // For media, convert media events to FeedItems - const mediaFeedItems: FeedItem[] = useMemo(() => - mediaEvents.map(event => ({ event, sortTimestamp: event.created_at })), - [mediaEvents] - ); - - // Whether the active tab is one of the legacy feed-driven core tabs that - // pulls items out of useProfileFeed / useProfileLikes / useProfileMedia / - // useWallComments. The new Agora-native core tabs (overview / campaigns / - // pledges / activity) have their own renderers below and intentionally - // bypass this fallthrough. - const isCoreProfileTab = activeTab === 'posts' || activeTab === 'replies' || activeTab === 'media' || activeTab === 'likes' || activeTab === 'badges'; - const currentItems = activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, isCoreProfileTab ? (activeTab as CoreProfileTab) : 'posts'); - const currentLoading = activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending; - const hasMore = activeTab === 'likes' ? hasNextLikesPage : activeTab === 'media' ? hasNextMediaPage : hasNextFeedPage; - const isFetchingMore = activeTab === 'likes' ? isFetchingNextLikesPage : activeTab === 'media' ? isFetchingNextMediaPage : isFetchingNextFeedPage; + // The Posts and Posts & replies tabs are the only feed-driven core tabs + // remaining; the Agora-native tabs (Activity / Campaigns / Pledges) have + // their own renderers below and bypass this fallthrough. + const isCoreProfileTab = activeTab === 'posts' || activeTab === 'replies'; + const currentItems = filterByTab(feedItems, isCoreProfileTab ? (activeTab as CoreProfileTab) : 'posts'); + const currentLoading = feedPending; + const hasMore = hasNextFeedPage; + const isFetchingMore = isFetchingNextFeedPage; // Auto-fetch next page when client-side filtering (e.g. removing replies // from the "posts" tab) leaves fewer visible items than the page size. @@ -1344,11 +1262,10 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe const MIN_VISIBLE_ITEMS = 5; useEffect(() => { if (currentLoading || isFetchingMore) return; - if (activeTab === 'likes' || activeTab === 'media') return; if (currentItems.length < MIN_VISIBLE_ITEMS && hasNextFeedPage && !isFetchingNextFeedPage) { fetchNextFeedPage(); } - }, [currentItems.length, currentLoading, isFetchingMore, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage]); + }, [currentItems.length, currentLoading, isFetchingMore, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage]); const handleRefresh = useCallback(async () => { if (!pubkey) return; @@ -1361,8 +1278,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe (tag === 'author' && key[1] === pubkey) || (tag === 'profile-supplementary' && key[1] === pubkey) || (tag === 'profile-feed' && key[1] === pubkey) || - (tag === 'profile-media' && key[1] === pubkey) || - (tag === 'profile-likes-infinite' && key[1] === pubkey) || (tag === 'profile-pinned-events' && key[1] === pubkey) ); }, @@ -1474,10 +1389,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe onMoreMenuOpen={() => setMoreMenuOpen(true)} onFollowQROpen={() => setFollowQROpen(true)} onToggleFollow={handleToggleFollow} - onTabChange={(id) => { - setActiveTab(id); - if (id === 'media') setSidebarMediaUrl(null); - }} + onTabChange={(id) => setActiveTab(id)} onDonate={openDonateForCampaign} canFollow={!!user} authorEvent={authorEvent} @@ -1489,26 +1401,12 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe `min-w-0` is critical inside a grid track so long unbroken text doesn't push the column wider than its fraction. */}
- {/* Tabs — fixed Agora-first set with an overflow `⋯` for the - legacy social tabs. Identical for owner and visitor. - Sticks to the top of this column as the user scrolls. */} - - {viewTabs.map((tab) => { - const tabId = CORE_TAB_IDS[tab.label] ?? tab.label; - return ( - { - setActiveTab(tabId); - if (tab.label === 'Media') setSidebarMediaUrl(null); - }} - className="flex-initial shrink-0 px-4" - /> - ); - })} - + {/* Profile tabs — sticky at top of this column. */} + ({ id: CORE_TAB_IDS[t.label] ?? t.label, label: t.label }))} + activeTab={activeTab} + onChange={setActiveTab} + /> {/* Campaigns tab. */} {activeTab === 'campaigns' && pubkey && ( @@ -1571,38 +1469,8 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe )} - {/* Media tab. */} - {activeTab === 'media' && ( -
- {mediaPending ? ( - - ) : mediaEvents.length > 0 ? ( - <> - setSidebarMediaUrl(null)} - hasNextPage={hasNextMediaPage} - isFetchingNextPage={isFetchingNextMediaPage} - onNearEnd={() => { if (hasNextMediaPage && !isFetchingNextMediaPage) fetchNextMediaPage(); }} - /> - {hasNextMediaPage && ( -
- )} - - ) : ( -
No media posts yet.
- )} -
- )} - - {/* Badges tab. */} - {activeTab === 'badges' && pubkey && ( - - )} - - {/* Posts / Replies / Likes — generic feed renderer. */} - {isCoreProfileTab && activeTab !== 'media' && activeTab !== 'badges' && ( + {/* Posts / Replies — generic feed renderer. */} + {isCoreProfileTab && (
{currentLoading ? (
@@ -1640,7 +1508,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
{activeTab === 'posts' && 'No posts yet.'} {activeTab === 'replies' && 'No posts or replies yet.'} - {activeTab === 'likes' && 'No likes yet.'}
)}
@@ -1717,159 +1584,3 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe ); } -// ─── Profile Badges Tab ─────────────────────────────────────────────────────── - -function ProfileBadgesTab({ pubkey, displayName }: { pubkey: string; displayName: string }) { - const { nostr } = useNostr(); - - // Fetch the user's profile badges event (both new kind 10008 and legacy 30008) - const profileBadgesQuery = useQuery({ - queryKey: ['profile-badges', pubkey], - queryFn: async () => { - const events = await nostr.query([ - { kinds: [10008], authors: [pubkey], limit: 1 }, - { kinds: [30008], authors: [pubkey], '#d': ['profile_badges'], limit: 1 }, - ]); - if (events.length === 0) return null; - // Pick the most recent event across both kinds - return events.reduce((latest, current) => - current.created_at > latest.created_at ? current : latest, - ); - }, - staleTime: 2 * 60_000, - }); - - // Parse badge references from the profile badges event - const badgeRefs = useMemo(() => { - if (!profileBadgesQuery.data) return []; - const tags = profileBadgesQuery.data.tags; - const refs: Array<{ aTag: string; eTag?: string; pubkey: string; identifier: string }> = []; - - for (let i = 0; i < tags.length; i++) { - if (tags[i][0] === 'a' && tags[i][1]) { - const aTag = tags[i][1]; - const parts = aTag.split(':'); - if (parts.length < 3 || parts[0] !== '30009') continue; - - const bPubkey = parts[1]; - const identifier = parts.slice(2).join(':'); - - let eTag: string | undefined; - if (i + 1 < tags.length && tags[i + 1][0] === 'e') { - eTag = tags[i + 1][1]; - } - - refs.push({ aTag, eTag, pubkey: bPubkey, identifier }); - } - } - // Deduplicate by aTag — keep first occurrence only - const seen = new Set(); - return refs.filter((r) => { - if (seen.has(r.aTag)) return false; - seen.add(r.aTag); - return true; - }); - }, [profileBadgesQuery.data]); - - // Fetch all referenced badge definitions - const badgeDefsQuery = useQuery({ - queryKey: ['badge-definitions-profile', pubkey, badgeRefs.map((r) => r.aTag).join(',')], - queryFn: async () => { - if (badgeRefs.length === 0) return []; - const filters = badgeRefs.map((ref) => ({ - kinds: [30009 as const], - authors: [ref.pubkey], - '#d': [ref.identifier], - limit: 1, - })); - return nostr.query(filters); - }, - enabled: badgeRefs.length > 0, - staleTime: 5 * 60_000, - }); - - // Build a lookup map from a-tag to parsed badge data - const badgeMap = useMemo(() => { - const map = new Map(); - if (!badgeDefsQuery.data) return map; - for (const event of badgeDefsQuery.data) { - const d = event.tags.find(([n]) => n === 'd')?.[1]; - if (!d) continue; - const aTag = `30009:${event.pubkey}:${d}`; - const name = event.tags.find(([n]) => n === 'name')?.[1] || d; - const thumbTag = event.tags.find(([n]) => n === 'thumb'); - const imageTag = event.tags.find(([n]) => n === 'image'); - const image = thumbTag?.[1] ?? imageTag?.[1]; - const description = event.tags.find(([n]) => n === 'description')?.[1]; - map.set(aTag, { name, image, description }); - } - return map; - }, [badgeDefsQuery.data]); - - if (profileBadgesQuery.isLoading) { - return ( -
-
- {Array.from({ length: 8 }).map((_, i) => ( -
- - -
- ))} -
-
- ); - } - - if (badgeRefs.length === 0) { - return ( -
- -

No badges yet

-

{displayName} hasn't accepted any badges.

-
- ); - } - - return ( -
-
- {badgeRefs.map((ref, idx) => { - const badge = badgeMap.get(ref.aTag); - const isLoading = badgeDefsQuery.isLoading; - const badgeUrl = `/${nip19.naddrEncode({ kind: 30009, pubkey: ref.pubkey, identifier: ref.identifier })}`; - - return ( - e.stopPropagation()} - > - {isLoading ? ( - - ) : badge?.image ? ( - {badge.name} - ) : ( -
- -
- )} - - {isLoading ? : (badge?.name || ref.identifier)} - - - ); - })} -
-
- ); -} -