From 70e78b7e5ffc6ff7d135664b57ed5b45006ad7f9 Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 21 May 2026 22:39:52 -0500 Subject: [PATCH 01/23] Refocus profile header on Agora: campaigns, pledges, raised, donate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the kind-1 posting streak chip with Agora-native stat chips (Campaigns / Pledges / Raised), opt out of FundraiserLayout's max-w-3xl cap so the profile can use a max-w-6xl canvas like CommunityDetailPage, and surface a Donate button (single-campaign direct, multi-campaign dropdown) next to Follow when the profile has at least one on-chain campaign. The stat chips wrap on narrow viewports and click through to the corresponding tab id ("campaigns" / "pledges") — those tabs land in commit 3. Adds useProfileCampaignStats, which fans receipt-verification queries out across the profile's campaigns in parallel using the same kind 8333 -> Esplora verification path as useCampaignDonations. Drops two leftover Ditto comments (relay.ditto.pub media note, "Ditto-style profiles" shape cleanup). The dead useLayoutOptions rightSidebar registration is removed — FundraiserLayout silently ignored it. --- src/components/EditProfileForm.tsx | 2 +- src/hooks/useProfileCampaignStats.ts | 111 ++++++++++++++ src/pages/ProfilePage.tsx | 213 +++++++++++++++++++-------- 3 files changed, 260 insertions(+), 66 deletions(-) create mode 100644 src/hooks/useProfileCampaignStats.ts diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index 3ecf7a9a..33495915 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -202,7 +202,7 @@ export const EditProfileForm: React.FC = ({ onValuesChange // Combine existing metadata with new values const data: Record = { ...metadata, ...standardMetadata }; - // Strip any legacy avatar shape data from old Ditto-style profiles + // Strip any legacy avatar-shape field carried over from older clients. delete data.shape; // Clean up empty values in standard metadata diff --git a/src/hooks/useProfileCampaignStats.ts b/src/hooks/useProfileCampaignStats.ts new file mode 100644 index 00000000..dc5eb8f5 --- /dev/null +++ b/src/hooks/useProfileCampaignStats.ts @@ -0,0 +1,111 @@ +import { useNostr } from '@nostrify/react'; +import { useQueries } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { useCampaigns } from '@/hooks/useCampaigns'; +import { useAppContext } from '@/hooks/useAppContext'; +import { + extractOnchainZapTxid, + verifyOnchainZap, +} from '@/hooks/useOnchainZaps'; +import type { ParsedCampaign } from '@/lib/campaign'; + +interface ProfileCampaignStats { + /** Total number of non-deleted campaigns authored by this pubkey. */ + campaignCount: number; + /** + * Sum of verified on-chain donations across all of this user's + * campaigns, in sats. Silent-payment campaigns contribute 0 by design + * (donations are unlinkable, no receipts are published). + */ + totalRaisedSats: number; + /** True while donation verification queries are still resolving. */ + isVerifying: boolean; + /** The raw campaigns list, for reuse by the chip click handler. */ + campaigns: ParsedCampaign[]; +} + +/** + * Aggregate campaign and donation stats for a single profile. + * + * Mirrors {@link useCampaignDonations} per campaign — fetches kind 8333 + * receipts targeting each `a` coord, dedupes by txid, and verifies each + * one on-chain against the campaign's `w` address before counting it + * toward the total. Silent-payment campaigns are excluded from the + * verification fan-out (their donations are intentionally unlinkable). + * + * Lazy: returns 0 / empty until the campaigns list resolves, then fans + * out receipt fetches in parallel. Suitable for header stat chips where + * an in-flight number is fine. + */ +export function useProfileCampaignStats(pubkey: string | undefined): ProfileCampaignStats { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; + + const campaignsQuery = useCampaigns( + pubkey ? { authors: [pubkey], limit: 100 } : { authors: [], limit: 0 }, + ); + const campaigns = pubkey ? (campaignsQuery.data ?? []) : []; + + // Fan out: one receipt fetch per on-chain campaign. + const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain'); + const receiptsQueries = useQueries({ + queries: onchainCampaigns.map((campaign) => ({ + queryKey: ['campaign-donations', 'events', campaign.aTag], + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + return nostr.query( + [{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }], + { signal }, + ); + }, + staleTime: 15_000, + })), + }); + + // Flatten the receipts and dedupe by txid (prefer earliest, like + // useCampaignDonations does). Track which campaign each txid belongs to + // so we can verify against the right wallet. + const verificationInputs: Array<{ campaign: ParsedCampaign; event: NostrEvent }> = []; + const seenByCampaign = new Map>(); + for (let i = 0; i < onchainCampaigns.length; i++) { + const campaign = onchainCampaigns[i]; + const receipts = receiptsQueries[i]?.data ?? []; + const sortedAsc = [...receipts].sort((a, b) => a.created_at - b.created_at); + const seenTxids = new Set(); + for (const event of sortedAsc) { + const txid = extractOnchainZapTxid(event); + if (!txid) continue; + if (seenTxids.has(txid)) continue; + seenTxids.add(txid); + verificationInputs.push({ campaign, event }); + } + seenByCampaign.set(campaign.aTag, seenTxids); + } + + const verifications = useQueries({ + queries: verificationInputs.map(({ campaign, event }) => ({ + queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, event.id, campaign.wallet?.value ?? ''], + queryFn: () => verifyOnchainZap(event, esploraBaseUrl, campaign.wallet?.value), + staleTime: 60_000, + enabled: !!campaign.wallet?.value, + })), + }); + + const totalRaisedSats = verifications.reduce( + (sum, v) => sum + (v.data?.amountSats ?? 0), + 0, + ); + + const isVerifying = + campaignsQuery.isLoading || + receiptsQueries.some((q) => q.isLoading) || + verifications.some((v) => v.isLoading); + + return { + campaignCount: campaigns.length, + totalRaisedSats, + isVerifying, + campaigns, + }; +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index cced0ebc..a9766007 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -6,7 +6,7 @@ import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Pencil, Trash2, RotateCcw, MessageSquare, Globe, Mail, Plus, GripVertical, ListPlus, Award, PanelLeft } from 'lucide-react'; +import { Zap, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Pencil, Trash2, RotateCcw, MessageSquare, Globe, Mail, Plus, GripVertical, ListPlus, Award, PanelLeft, HandHeart, Megaphone } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { useLayoutOptions } from '@/contexts/LayoutContext'; -import { ProfileRightSidebar } from '@/components/ProfileRightSidebar'; import { NoteCard } from '@/components/NoteCard'; import { FeedCard } from '@/components/FeedCard'; import { ComposeBox } from '@/components/ComposeBox'; @@ -83,6 +82,12 @@ import { } from '@dnd-kit/sortable'; import { CSS as DndCSS } from '@dnd-kit/utilities'; import { formatNumber } from '@/lib/formatNumber'; +import { formatCampaignAmount } from '@/lib/formatCampaignAmount'; +import { useProfileCampaignStats } from '@/hooks/useProfileCampaignStats'; +import { useActions } from '@/hooks/useActions'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; +import { DonateDialog } from '@/components/DonateDialog'; +import type { ParsedCampaign } from '@/lib/campaign'; import { SubHeaderBar } from '@/components/SubHeaderBar'; import { useActiveTabIndicator } from '@/components/SubHeaderBarContext'; import { TabButton } from '@/components/TabButton'; @@ -97,32 +102,6 @@ import QRCode from 'qrcode'; import { isWeatherFieldLabel } from '@/lib/weatherStation'; import { WeatherStationCard } from '@/components/WeatherStationCard'; -const STREAK_WINDOW_HOURS = 24; -const STREAK_DISPLAY_LIMIT = 99; - -/** Calculate posting streak: consecutive kind 1 posts within 24-hour windows. */ -function calculateStreak(posts: NostrEvent[]): number { - if (!posts || posts.length === 0) return 0; - - const kind1Posts = posts.filter((e) => e.kind === 1); - if (kind1Posts.length === 0) return 0; - - const sorted = [...kind1Posts].sort((a, b) => b.created_at - a.created_at); - const windowSeconds = STREAK_WINDOW_HOURS * 3600; - - let streak = 1; - for (let i = 0; i < sorted.length - 1; i++) { - const gap = sorted[i].created_at - sorted[i + 1].created_at; - if (gap <= windowSeconds) { - streak++; - } else { - break; - } - } - - return streak; -} - /** Parse the custom "fields" array from kind 0 metadata content. */ function parseProfileFields(content: string): Array<{ label: string; value: string }> { try { @@ -1369,7 +1348,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; description: metadata?.about || 'Nostr profile', }); - // Profile media — dedicated search query via relay.ditto.pub (video:true image:true) + // Profile media — NIP-50 `media:true` search via the configured read pool. const { data: mediaData, isPending: mediaPending, @@ -1432,6 +1411,28 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; const { data: userStats } = useNip85UserStats(pubkey); const followersCount = userStats?.followers ?? 0; + // Agora stat sources: campaigns + raised totals, and pledges count. + const profileCampaignStats = useProfileCampaignStats(pubkey); + const { data: btcPrice } = useBtcPrice(); + // Pledges (kind 36639) authored by this user. Filters the global pledges + // list rather than issuing a separate per-author query. + const { data: allActions } = useActions({ limit: 100 }); + const profileActionsCount = useMemo(() => { + if (!pubkey || !allActions) return 0; + return allActions.filter((a) => a.pubkey === pubkey).length; + }, [allActions, pubkey]); + + // Donate dialog state. The header "Donate" button (only shown when the + // profile has at least one campaign) opens this dialog. When the user + // has multiple campaigns the action bar surfaces a dropdown that picks + // which campaign to donate to first. + const [donateOpen, setDonateOpen] = useState(false); + const [donateCampaign, setDonateCampaign] = useState(null); + const openDonateForCampaign = useCallback((campaign: ParsedCampaign) => { + setDonateCampaign(campaign); + setDonateOpen(true); + }, []); + const isOwnProfile = user?.pubkey === pubkey; const { feedSettings } = useFeedSettings(); @@ -1584,17 +1585,6 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; })); }, [wallComments, pubkey]); - const streak = useMemo(() => { - if (!feedData?.pages) return 0; - const events: NostrEvent[] = []; - for (const page of feedData.pages) { - for (const item of page.items) { - events.push(item.event); - } - } - return calculateStreak(events); - }, [feedData?.pages]); - // Infinite scroll sentinel const { ref: scrollRef, inView } = useInView({ threshold: 0, @@ -1675,13 +1665,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; const openWallCompose = useCallback(() => setWallComposeOpen(true), []); - const handleSidebarMediaClick = useCallback((url: string) => { - setActiveTab('media'); - setSidebarMediaUrl(url); - }, []); - + // ProfilePage opts out of FundraiserLayout's default `max-w-3xl` cap so it + // can run a wider canvas (banner full-bleed, contained `max-w-6xl` content + // column) matching CampaignsPage / CommunityDetailPage. FundraiserLayout has + // no right-sidebar slot, so any `rightSidebar` option here would be ignored. useLayoutOptions(pubkey ? { - rightSidebar: , + noMaxWidth: true, showFAB: !(activeTab === 'wall' && !profileFollowsMe), onFabClick: activeTab === 'wall' ? openWallCompose : undefined, hasSubHeader: true, @@ -1691,9 +1680,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // If we're resolving a NIP-05, show loading state if (isNip05Param && nip05Loading) { return ( -
+
-
+
@@ -1706,8 +1695,8 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // If NIP-05 resolved to null (not found), show error if (isNip05Param && !nip05Loading) { return ( -
-
+
+

User not found: {npub}

Could not resolve this NIP-05 identifier.

@@ -1715,8 +1704,8 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; ); } return ( -
-
+
+

User not found.

@@ -1724,9 +1713,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; } return ( -
+
- {/* Banner */} + {/* Banner — kept full-bleed, outside the constrained content container. */}
{author.isLoading ? ( @@ -1741,8 +1730,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
+ {/* Constrained content canvas — wider than FundraiserLayout's default + max-w-3xl, narrower than the campaign directory. Banner stays + outside this container so it remains full-bleed. */} +
{/* Profile info */} -
+
{author.isLoading ? ( <>
@@ -1833,6 +1826,53 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; disabled={!user} /> )} + {/* Donate button — only shown when the profile has at least one + on-chain campaign. SP-only campaigns are excluded because + DonateDialog only supports on-chain donations; donors hit + the campaign detail page directly for SP. */} + {!isOwnProfile && (() => { + const onchain = profileCampaignStats.campaigns.filter( + (c) => c.wallet?.mode === 'onchain', + ); + if (onchain.length === 0) return null; + if (onchain.length === 1) { + return ( + + ); + } + return ( + + + + + + {onchain.map((c) => ( + openDonateForCampaign(c)} + className="flex flex-col items-start gap-0.5" + > + {c.title} + {c.goalUsd ? ( + + Goal ${c.goalUsd.toLocaleString()} + + ) : null} + + ))} + + + ); + })()}
@@ -1859,7 +1899,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; )} {/* Followers / Following count + Streak indicator */} -
+ {/* Stat chips: Followers · Following · Campaigns · Pledges · Raised. + Wraps to multiple rows on narrow viewports; single row from sm+. */} +
{followersCount > 0 && ( )} - {streak > 1 && ( -
STREAK_DISPLAY_LIMIT ? `${STREAK_DISPLAY_LIMIT}+` : streak} posts within ${STREAK_WINDOW_HOURS}h windows`} + {profileCampaignStats.campaignCount > 0 && ( +
+ + {formatNumber(profileCampaignStats.campaignCount)} + {profileCampaignStats.campaignCount === 1 ? 'campaign' : 'campaigns'} + + )} + {profileActionsCount > 0 && ( + + )} + {profileCampaignStats.totalRaisedSats > 0 && ( + )}
@@ -2329,6 +2393,24 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; /> )} + {/* Donate dialog — driven by the header Donate button and (later) + campaign cards / dropdown rows. Resets the active campaign on + close so reopening starts fresh. */} + {donateCampaign && ( + { + setDonateOpen(open); + if (!open) { + // Invalidate donations cache so the new total reflects in stats. + queryClient.invalidateQueries({ queryKey: ['campaign-donations', 'events', donateCampaign.aTag] }); + } + }} + btcPrice={btcPrice} + /> + )} + {/* Image lightbox for avatar/banner */} {lightboxImage && ( )} +
); From c738b60c7b10f738646beb3623a6974787b83657 Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 21 May 2026 22:44:41 -0500 Subject: [PATCH 02/23] Add Campaigns + Organizations hero strips and two-column profile body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert two responsive hero rows between the profile header and the tab strip: - ProfileCampaignsStrip — renders the profile owner's campaigns as a full CampaignCard grid (1/2/3/4 columns at sm/lg/xl), capped at 6 with a 'View all' link into the (forthcoming) Campaigns tab. Filters hidden campaigns out for visitors; own-profile sees them so creators understand their moderation state. - ProfileOrganizationsStrip — renders orgs the profile founded or moderates as CommunityMiniCards with a Founder / Moderator badge overlay. Backed by a new useProfileOrganizations hook that takes any pubkey and only surfaces public signals — kind 34550 author and kind 34550 #p moderator entries. The 'follows' axis (kind 10004 bookmarks) is intentionally not shown because bookmarks are private state that another viewer can't see. Both strips self-collapse when empty, so a Nostr-native profile with no Agora activity remains compact. The body below the tab strip becomes a two-column grid at lg+: a 300 px sticky left rail (the existing ProfileRightSidebar, now inlined into a grid cell via a new variant='inline' prop) and a min-w-0 right column for the tab content. Below lg the rail is hidden and tab content flows full-width — the rail's profile-fields content is already shown inline in the mobile header, so nothing is lost. The legacy 'xl:hidden' inline profile-fields breakpoint shifts to 'lg:hidden' so the rail and the inline fields don't double up at lg+. --- src/components/ProfileRightSidebar.tsx | 18 ++- .../profile/ProfileCampaignsStrip.tsx | 107 ++++++++++++++++++ .../profile/ProfileOrganizationsStrip.tsx | 82 ++++++++++++++ src/hooks/useProfileOrganizations.ts | 92 +++++++++++++++ src/pages/ProfilePage.tsx | 54 ++++++++- 5 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 src/components/profile/ProfileCampaignsStrip.tsx create mode 100644 src/components/profile/ProfileOrganizationsStrip.tsx create mode 100644 src/hooks/useProfileOrganizations.ts diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx index f14fcb24..6cf5178e 100644 --- a/src/components/ProfileRightSidebar.tsx +++ b/src/components/ProfileRightSidebar.tsx @@ -82,6 +82,16 @@ interface ProfileRightSidebarProps { onMediaClick?: (url: string) => void; /** Override the root element's className (e.g. to show on mobile). */ className?: string; + /** + * Layout variant. + * + * - `'rail'` (default) — legacy 1/4-width fixed sidebar with full-height + * sticky scrolling. Designed for the old MainLayout shell. + * - `'inline'` — fills its parent's width with no positioning, so it can + * be slotted into a grid cell or another container that manages + * layout (e.g. the ProfilePage two-column body). + */ + variant?: 'rail' | 'inline'; } interface MediaItem { @@ -495,7 +505,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh return rows; } -export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) { +export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className, variant = 'rail' }: ProfileRightSidebarProps) { const { config } = useAppContext(); const { nostr } = useNostr(); @@ -534,8 +544,12 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]); + const rootClass = variant === 'inline' + ? 'flex flex-col w-full' + : 'w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3'; + return ( -
)} - {/* Profile fields shown inline on mobile (sidebar is hidden below xl) */} + {/* Profile fields shown inline on mobile/tablet (sidebar appears at lg+). */} {fields.length > 0 && ( -
+
{fields.map((field, i) => ( ))} @@ -1998,6 +2001,21 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; )}
+ {/* Agora hero strips — full-width within the constrained content + canvas. Each strip self-collapses when empty, so a Nostr-native + profile with no Agora activity stays compact. */} + {pubkey && ( +
+ setActiveTab('campaigns')} + /> + +
+ )} + {/* Tabs */} {/* Skeleton while kind 16769 is loading */} @@ -2139,6 +2157,35 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; )} + {/* Two-column body — sticky identity rail on the left (≥ lg), tab + content on the right. Below lg the rail is hidden and tab content + flows full-width; the identity it would carry (profile fields, + media) is already visible inline in the header on mobile and via + the dedicated Media tab. */} +
+ {/* Left rail — only mounts at lg+, inlined into a grid cell because + FundraiserLayout has no built-in right-sidebar slot. The legacy + `w-1/4 sticky h-screen` styling is overridden so the aside fits + the grid cell and sticks correctly inside the page scroll. */} + {pubkey && ( + + )} + + {/* Tab-content column — `min-w-0` is critical inside a grid track + so long unbroken text doesn't push the column wider than its + fraction allowance. */} +
+ {/* Add/edit single tab modal */} {pubkey && ( )} +
+
+ {/* Profile More Menu */} {pubkey && ( Date: Thu, 21 May 2026 22:51:40 -0500 Subject: [PATCH 03/23] Restructure profile tabs around Agora activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-order CORE_TAB_LABELS so the Agora-native tabs lead and the legacy social tabs fall back to the overflow menu: Overview · Campaigns · Pledges · Activity · Posts · Wall (default) + Posts & replies · Media · Badges · Likes (overflow) Overview becomes the default landing tab and replaces 'Posts' as the first visible card on a fresh profile. CORE_TAB_FILTERS gets matching NIP-01 filter entries for each new tab so kind 16769 events remain interpretable by non-Agora clients (Campaigns → kinds:[33863] + author, Pledges → kinds:[36639] + author, Activity → the Agora-feed kind set plus #t:agora, Overview → the same Posts shape as a safe default). The four new tab renderers live in src/components/profile/: - ProfileOverviewTab — composite: featured campaign + recent Agora activity preview + 3 most recent posts, with section-level 'see all' links into the deeper tabs. Empty-state shows own-profile CTAs to start a campaign / create a pledge. - ProfileCampaignsTab — full grid of the user's campaigns with New / Top sort and a Show-hidden toggle gated to the owner + Team Soapbox moderators. 'Top' sort fans out one receipts query + per-receipt verification across the visible set via useQueries (no rules-of-hooks violation), keying into the same caches as useCampaignDonations so verifier results are shared. - ProfilePledgesTab — pledges created by the user, split into Active and Ended groups, with a card visual that mirrors /pledges. 'Pledges backed' (zapped submissions on others' pledges) is intentionally deferred to v2 per the design plan. - ProfileActivityTab — useAgoraFeed scoped to a single author with infinite scroll; renders the mixed-kind timeline through NoteCard. The 'I Have No Mouth, and I Must Scream' empty state on profiles whose kind 16769 publishes an empty tab list is replaced with a neutral one-liner — the Harlan Ellison quote rotation was an upstream Easter egg that clashes with Agora's framing. --- src/components/profile/ProfileActivityTab.tsx | 93 ++++++++ .../profile/ProfileCampaignsTab.tsx | 222 +++++++++++++++++ src/components/profile/ProfileOverviewTab.tsx | 203 ++++++++++++++++ src/components/profile/ProfilePledgesTab.tsx | 223 ++++++++++++++++++ src/pages/ProfilePage.tsx | 104 +++++--- 5 files changed, 816 insertions(+), 29 deletions(-) create mode 100644 src/components/profile/ProfileActivityTab.tsx create mode 100644 src/components/profile/ProfileCampaignsTab.tsx create mode 100644 src/components/profile/ProfileOverviewTab.tsx create mode 100644 src/components/profile/ProfilePledgesTab.tsx diff --git a/src/components/profile/ProfileActivityTab.tsx b/src/components/profile/ProfileActivityTab.tsx new file mode 100644 index 00000000..d82022bb --- /dev/null +++ b/src/components/profile/ProfileActivityTab.tsx @@ -0,0 +1,93 @@ +import { useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { Loader2, Sparkles } from 'lucide-react'; + +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { NoteCard } from '@/components/NoteCard'; +import { useAgoraFeed } from '@/hooks/useAgoraFeed'; + +interface ProfileActivityTabProps { + pubkey: string; + displayName: string; +} + +/** + * Unified Agora activity feed scoped to one author. + * + * Pipes {@link useAgoraFeed} through with `authors=[pubkey]`, so the + * relay-side filter does the work — the result is a mixed-kind timeline + * of this author's campaigns, pledges, communities, Agora-marked notes, + * comments, and on-chain zap receipts. `NoteCard` renders all of these + * kinds; rendering details live there. + * + * Single-column inside the tab area because the timeline is mixed-kind + * and benefits from full-width cards. + */ +export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabProps) { + const { + events, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useAgoraFeed(true, { authors: [pubkey] }); + + const { ref: scrollRef, inView } = useInView({ threshold: 0 }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading && events.length === 0) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+ + +
+ + +
+ ))} +
+ ); + } + + if (events.length === 0) { + return ( +
+ +
+ +

+ No Agora activity from {displayName} yet. Campaigns, pledges, + and on-chain donations show up here. +

+
+
+
+ ); + } + + return ( +
+
+ {events.map((event) => ( + + ))} +
+ {hasNextPage && ( +
+ {isFetchingNextPage && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/profile/ProfileCampaignsTab.tsx b/src/components/profile/ProfileCampaignsTab.tsx new file mode 100644 index 00000000..8168cd48 --- /dev/null +++ b/src/components/profile/ProfileCampaignsTab.tsx @@ -0,0 +1,222 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Megaphone } from 'lucide-react'; +import { useNostr } from '@nostrify/react'; +import { useQueries } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { useCampaignModeration } from '@/hooks/useCampaignModeration'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAppContext } from '@/hooks/useAppContext'; +import { + extractOnchainZapTxid, + verifyOnchainZap, +} from '@/hooks/useOnchainZaps'; +import type { ParsedCampaign } from '@/lib/campaign'; + +interface ProfileCampaignsTabProps { + pubkey: string; + displayName: string; + isOwnProfile: boolean; + campaigns: ParsedCampaign[]; + isLoading: boolean; +} + +type SortMode = 'top' | 'new'; + +/** + * Full grid of every campaign authored by this profile. + * + * Owner / moderator can toggle "Show hidden" to see campaigns the + * moderation pack has hidden from the home page — visitors only see + * non-hidden campaigns by default. Sort modes mirror + * {@link AllCampaignsPage}: New (newest created_at first, the default + * incoming order) and Top (most sats raised, requires the verified + * donation totals). + */ +export function ProfileCampaignsTab({ + pubkey, + displayName, + isOwnProfile, + campaigns, + isLoading, +}: ProfileCampaignsTabProps) { + const { user } = useCurrentUser(); + const { data: moderation } = useCampaignModeration(); + const { data: moderators } = useCampaignModerators(); + const isModerator = !!user && (moderators ?? []).includes(user.pubkey); + + const [sortMode, setSortMode] = useState('new'); + const [showHidden, setShowHidden] = useState(false); + + const canShowHidden = isOwnProfile || isModerator; + + const filtered = useMemo(() => { + if (canShowHidden && showHidden) return campaigns; + return campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag)); + }, [campaigns, canShowHidden, showHidden, moderation.hiddenCoords]); + + if (isLoading && filtered.length === 0) { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (filtered.length === 0) { + return ( +
+ +
+ +

+ {isOwnProfile + ? "You haven't launched a campaign yet." + : `${displayName} hasn't launched a campaign yet.`} +

+ {isOwnProfile && ( + + Start a campaign → + + )} +
+
+
+ ); + } + + return ( +
+
+

+ {filtered.length} {filtered.length === 1 ? 'campaign' : 'campaigns'} +

+
+ + + {canShowHidden && ( + + )} +
+
+ + {sortMode === 'top' ? ( + + ) : ( +
+ {filtered.map((c) => ( + + ))} +
+ )} +
+ ); +} + +/** + * Sorts the visible campaigns by verified sats raised (descending) by + * fanning out one receipts query + per-receipt verification across all + * campaigns at once. Uses `useQueries`, so the hook call count is + * deterministic per render (one queries-tuple, not one hook per campaign) + * and the rules of hooks hold. + * + * Caches share keys with `useCampaignDonations` so the verifier results + * are reused across the profile and any other view of the same campaign. + */ +function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; + + // Only on-chain campaigns can have verifiable totals. SP campaigns sort to 0. + const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain'); + + // Step 1: one receipts query per on-chain campaign. + const receiptsQueries = useQueries({ + queries: onchain.map((campaign) => ({ + queryKey: ['campaign-donations', 'events', campaign.aTag], + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + return nostr.query( + [{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }], + { signal }, + ); + }, + staleTime: 15_000, + })), + }); + + // Step 2: dedupe receipts by txid (earliest wins, matching useCampaignDonations). + const verificationInputs: Array<{ aTag: string; wallet: string; event: NostrEvent }> = []; + for (let i = 0; i < onchain.length; i++) { + const campaign = onchain[i]; + const wallet = campaign.wallet?.value; + if (!wallet) continue; + const receipts = receiptsQueries[i]?.data ?? []; + const ascending = [...receipts].sort((a, b) => a.created_at - b.created_at); + const seenTxids = new Set(); + for (const event of ascending) { + const txid = extractOnchainZapTxid(event); + if (!txid || seenTxids.has(txid)) continue; + seenTxids.add(txid); + verificationInputs.push({ aTag: campaign.aTag, wallet, event }); + } + } + + const verifications = useQueries({ + queries: verificationInputs.map(({ wallet, event }) => ({ + queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, event.id, wallet], + queryFn: () => verifyOnchainZap(event, esploraBaseUrl, wallet), + staleTime: 60_000, + })), + }); + + // Step 3: sum verified sats per campaign aTag. + const totalsByCoord = new Map(); + for (let i = 0; i < verifications.length; i++) { + const { aTag } = verificationInputs[i]; + const sats = verifications[i].data?.amountSats ?? 0; + totalsByCoord.set(aTag, (totalsByCoord.get(aTag) ?? 0) + sats); + } + + const sorted = [...campaigns].sort( + (a, b) => (totalsByCoord.get(b.aTag) ?? 0) - (totalsByCoord.get(a.aTag) ?? 0), + ); + + return ( +
+ {sorted.map((campaign) => ( + + ))} +
+ ); +} diff --git a/src/components/profile/ProfileOverviewTab.tsx b/src/components/profile/ProfileOverviewTab.tsx new file mode 100644 index 00000000..6f73803a --- /dev/null +++ b/src/components/profile/ProfileOverviewTab.tsx @@ -0,0 +1,203 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useInView } from 'react-intersection-observer'; +import { useEffect } from 'react'; +import { ArrowRight, Megaphone, Sparkles, MessageCircle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { CampaignCard } from '@/components/CampaignCard'; +import { NoteCard } from '@/components/NoteCard'; +import { useAgoraFeed } from '@/hooks/useAgoraFeed'; +import type { FeedItem } from '@/lib/feedUtils'; +import type { ParsedCampaign } from '@/lib/campaign'; + +interface ProfileOverviewTabProps { + pubkey: string; + displayName: string; + isOwnProfile: boolean; + campaigns: ParsedCampaign[]; + /** Recent posts (kind 1 / 6) by this user, already filtered upstream. */ + recentPosts: FeedItem[]; + onSeeAllPosts: () => void; + onSeeAllActivity: () => void; + onSeeAllCampaigns: () => void; +} + +/** + * Overview is the default landing tab for a profile — a composite of + * the highest-signal sections so a visitor sees what someone is *doing* + * on Agora before they decide which detail tab to drill into. + * + * Sections (each renders only when there's content): + * + * 1. Featured campaign — the user's campaign with the most raised so far. + * 2. Recent activity — first 5–8 items from useAgoraFeed scoped to this + * author. Mixed kinds (campaigns, pledges, comments, zaps, etc.). + * 3. Recent posts — first 3 kind 1 / 6 notes for the "still Nostr" + * touchpoint. + * + * If all sections are empty we show a friendly empty state with own-profile + * CTAs to start a campaign or write a post. + */ +export function ProfileOverviewTab({ + pubkey, + displayName, + isOwnProfile, + campaigns, + recentPosts, + onSeeAllPosts, + onSeeAllActivity, + onSeeAllCampaigns, +}: ProfileOverviewTabProps) { + const { events: activityEvents, fetchNextPage, hasNextPage, isFetchingNextPage } = + useAgoraFeed(true, { authors: [pubkey] }); + + // Choose a single highlight campaign — first non-hidden one (campaigns are + // sorted newest-first by useCampaigns). The Strip above already lists all + // visible ones, so Overview just spotlights one. + const featured = campaigns[0]; + + // Trim activity to a preview. `useAgoraFeed` already returns enriched + // donation events alongside Agora entities; the first ~8 are typically + // the freshest activity beats. + const previewActivity = useMemo(() => activityEvents.slice(0, 8), [activityEvents]); + + // Light infinite-load: if the Overview is the only tab the user looks at + // and they scroll near the bottom, pull a second page so the visible + // preview stays fresh. The full timeline still lives in the Activity tab. + const { ref: sentinelRef, inView } = useInView({ threshold: 0 }); + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage && activityEvents.length < 8) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, activityEvents.length]); + + const hasFeatured = !!featured; + const hasActivity = previewActivity.length > 0; + const hasPosts = recentPosts.length > 0; + const isFullyEmpty = !hasFeatured && !hasActivity && !hasPosts; + + if (isFullyEmpty) { + return ( +
+ +
+ +

+ {isOwnProfile + ? "Nothing here yet. Launch a campaign, create a pledge, or post a note to fill out your profile." + : `${displayName} hasn't posted anything yet.`} +

+ {isOwnProfile && ( +
+ + +
+ )} +
+
+
+ ); + } + + return ( +
+ {/* Featured campaign — single wide card, click-through to the campaign. */} + {hasFeatured && ( +
+ } + title="Featured campaign" + onSeeAll={campaigns.length > 1 ? onSeeAllCampaigns : undefined} + seeAllLabel="All campaigns" + /> + +
+ )} + + {/* Recent activity — mixed-kind list from the Agora feed. */} + {hasActivity && ( +
+ } + title="Recent activity" + onSeeAll={onSeeAllActivity} + seeAllLabel="See all" + /> + +
+ {previewActivity.map((event) => ( + + ))} +
+
+ {/* Off-screen sentinel that pulls another page lazily so the + Overview preview isn't visibly empty for active users. */} +
+
+ )} + + {/* Recent posts — the "still Nostr" section. */} + {hasPosts && ( +
+ } + title="Recent posts" + onSeeAll={onSeeAllPosts} + seeAllLabel="All posts" + /> + +
+ {recentPosts.map((item) => ( + + ))} +
+
+
+ )} +
+ ); +} + +function SectionHeader({ + icon, + title, + onSeeAll, + seeAllLabel, +}: { + icon: React.ReactNode; + title: string; + onSeeAll?: () => void; + seeAllLabel: string; +}) { + return ( +
+

+ {icon} + {title} +

+ {onSeeAll && ( + + )} +
+ ); +} diff --git a/src/components/profile/ProfilePledgesTab.tsx b/src/components/profile/ProfilePledgesTab.tsx new file mode 100644 index 00000000..b9432fa8 --- /dev/null +++ b/src/components/profile/ProfilePledgesTab.tsx @@ -0,0 +1,223 @@ +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { CalendarClock, HandHeart, MapPin } from 'lucide-react'; +import { nip19 } from 'nostr-tools'; + +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; +import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges'; +import { getGeoDisplayName } from '@/lib/countries'; +import { cn } from '@/lib/utils'; +import type { Action } from '@/hooks/useActions'; + +interface ProfilePledgesTabProps { + pubkey: string; + displayName: string; + isOwnProfile: boolean; + /** Pledges authored by this pubkey. Already filtered upstream. */ + pledges: Action[]; + /** BTC price for sats↔USD conversion in pledge amount labels. */ + btcPrice: number | undefined; + /** True while the underlying useActions() query is still in flight. */ + isLoading: boolean; +} + +/** + * Pledges authored by this profile, rendered as a responsive grid that + * mirrors the `/pledges` (`ActionsPage`) directory styling. + * + * v1 scope per the design plan: pledges *created* by the user. + * "Pledges backed" (zapped submissions on others' pledges) is deferred to v2. + */ +export function ProfilePledgesTab({ + pubkey, + displayName, + isOwnProfile, + pledges, + btcPrice, + isLoading, +}: ProfilePledgesTabProps) { + const now = Math.floor(Date.now() / 1000); + + // Loading skeleton until the first list resolves. + if (isLoading && pledges.length === 0) { + return ( +
+ +
+ ); + } + + if (pledges.length === 0) { + return ( +
+ +
+ +

+ {isOwnProfile + ? "You haven't created a pledge yet." + : `${displayName} hasn't created a pledge yet.`} +

+ {isOwnProfile && ( + + Create a pledge → + + )} +
+
+
+ ); + } + + // Split into active vs ended so the active pledges lead the grid. + const active: Action[] = []; + const ended: Action[] = []; + for (const p of pledges) { + if (p.deadline && p.deadline <= now) ended.push(p); + else active.push(p); + } + + return ( +
+ {active.length > 0 && ( +
+ {ended.length > 0 && ( +

+ Active +

+ )} +
+ {active.map((pledge) => ( + + ))} +
+
+ )} + + {ended.length > 0 && ( +
+

+ Ended +

+
+ {ended.map((pledge) => ( + + ))} +
+
+ )} +
+ ); +} + +function ProfilePledgeCard({ + action, + isExpired, + btcPrice, +}: { + action: Action; + isExpired?: boolean; + btcPrice: number | undefined; +}) { + const [imageLoadFailed, setImageLoadFailed] = useState(false); + + const naddr = nip19.naddrEncode({ + kind: 36639, + pubkey: action.pubkey, + identifier: action.id, + }); + + const coverImage = (action.image && !imageLoadFailed) ? action.image : DEFAULT_COVER_IMAGE; + const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null; + const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined; + + return ( + + +
+ setImageLoadFailed(true)} + loading="lazy" + /> + {isExpired && ( + + Ended + + )} +
+ +
+

+ {action.title} +

+ {action.description.trim() && ( +

{action.description}

+ )} + +
+ +
+

Pledged

+

+ {formatPledgeAmount(action.bounty, btcPrice)} +

+
+ +
+ {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} +
+
+ + + ); +} + +function PledgesGridSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + +
+ + + + +
+
+ ))} +
+ ); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 1817e612..38083965 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -89,6 +89,10 @@ import { useBtcPrice } from '@/hooks/useBtcPrice'; import { DonateDialog } from '@/components/DonateDialog'; import { ProfileCampaignsStrip } from '@/components/profile/ProfileCampaignsStrip'; import { ProfileOrganizationsStrip } from '@/components/profile/ProfileOrganizationsStrip'; +import { ProfileOverviewTab } from '@/components/profile/ProfileOverviewTab'; +import { ProfileCampaignsTab } from '@/components/profile/ProfileCampaignsTab'; +import { ProfilePledgesTab } from '@/components/profile/ProfilePledgesTab'; +import { ProfileActivityTab } from '@/components/profile/ProfileActivityTab'; import { ProfileRightSidebar } from '@/components/ProfileRightSidebar'; import type { ParsedCampaign } from '@/lib/campaign'; import { SubHeaderBar } from '@/components/SubHeaderBar'; @@ -968,13 +972,14 @@ function ProfileBannerImage({ src, onClick }: { src: string; onClick: () => void // ----- Main Component ----- -const CORE_TAB_LABELS = ['Posts', 'Posts & replies', 'Media', 'Badges', 'Likes', 'Wall']; -const DEFAULT_TAB_LABELS = ['Posts', 'Posts & replies', 'Media', 'Likes', 'Wall']; +const CORE_TAB_LABELS = ['Overview', 'Campaigns', 'Pledges', 'Activity', 'Posts', 'Posts & replies', 'Media', 'Wall', 'Badges', 'Likes']; +const DEFAULT_TAB_LABELS = ['Overview', 'Campaigns', 'Pledges', 'Activity', 'Posts', 'Wall']; // Map from display label → internal tab id for core tabs const CORE_TAB_IDS: Record = { - 'Posts': 'posts', 'Posts & replies': 'replies', - 'Media': 'media', 'Badges': 'badges', 'Likes': 'likes', 'Wall': 'wall', + 'Overview': 'overview', 'Campaigns': 'campaigns', 'Pledges': 'pledges', + 'Activity': 'activity', 'Posts': 'posts', 'Posts & replies': 'replies', + 'Media': 'media', 'Wall': 'wall', 'Badges': 'badges', 'Likes': 'likes', }; export function ProfilePage() { @@ -987,7 +992,7 @@ export function ProfilePage() { const { muteItems } = useMuteList(); const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState('posts'); + const [activeTab, setActiveTab] = useState('overview'); const [sidebarMediaUrl, setSidebarMediaUrl] = useState(null); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [followQROpen, setFollowQROpen] = useState(false); @@ -1214,7 +1219,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // Derive the ID of the first visible tab (used as default selection). const firstTabId = useMemo(() => { - if (viewTabs.length === 0) return 'posts'; + if (viewTabs.length === 0) return 'overview'; const first = viewTabs[0]; return CORE_TAB_IDS[first.label] ?? first.label; }, [viewTabs]); @@ -1248,7 +1253,14 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // Canonical NIP-01 filters for core tabs so other clients can interpret the event. // Values are interpolated with the actual pubkey (not $me) since these are concrete filters. + // For the Agora-native tabs (Overview / Campaigns / Pledges / Activity) we pick + // sensible NIP-01 filters that other Nostr clients can still read meaningfully; + // Agora-aware clients render them as their richer views. const CORE_TAB_FILTERS: Record = pubkey ? { + 'Overview': { kinds: [1, 6], authors: [pubkey] }, + 'Campaigns': { kinds: [33863], authors: [pubkey] }, + 'Pledges': { kinds: [36639], authors: [pubkey] }, + 'Activity': { kinds: [33863, 36639, 34550, 8333, 1, 1111], authors: [pubkey], '#t': ['agora', 'Agora'] }, 'Posts': { kinds: [1, 6], authors: [pubkey] }, 'Posts & replies': { authors: [pubkey] }, 'Media': { kinds: [1], authors: [pubkey] }, @@ -1267,7 +1279,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // If the active tab was removed, fall back to the first remaining tab const remainingIds = localTabs.map((t) => CORE_TAB_IDS[t.label] ?? t.label); if (!remainingIds.includes(activeTab)) { - setActiveTab(remainingIds[0] ?? 'posts'); + setActiveTab(remainingIds[0] ?? 'overview'); } setTabEditMode(false); }; @@ -1297,7 +1309,10 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // Drop active tab if it was deleted useEffect(() => { - const isCoreTab = ['posts', 'replies', 'media', 'badges', 'likes', 'wall'].includes(activeTab); + // Core ids correspond to the new core tab set + the legacy ones the + // overflow dropdown can still reach. If the active tab id is none of + // those AND not in the user's saved tabs, fall back to the first tab. + const isCoreTab = ['overview', 'campaigns', 'pledges', 'activity', 'posts', 'replies', 'media', 'badges', 'likes', 'wall'].includes(activeTab); if (!isCoreTab && !profileSavedTabs.find((t) => t.label === activeTab)) { setActiveTab(firstTabId); } @@ -1628,6 +1643,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; [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 === 'wall' || activeTab === 'badges'; const currentItems = activeTab === 'wall' ? [] : activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, isCoreProfileTab ? (activeTab as CoreProfileTab) : 'posts'); const currentLoading = activeTab === 'wall' ? wallPending : activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending; @@ -2203,6 +2223,51 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; )} + {/* Overview tab — composite landing view with pinned post, featured + campaign, recent Agora activity, and a short list of recent posts. */} + {hasTabs && activeTab === 'overview' && pubkey && ( + setActiveTab('posts')} + onSeeAllActivity={() => setActiveTab('activity')} + onSeeAllCampaigns={() => setActiveTab('campaigns')} + /> + )} + + {/* Campaigns tab — every campaign by this author, including hidden + ones for the owner and moderators. */} + {hasTabs && activeTab === 'campaigns' && pubkey && ( + + )} + + {/* Pledges tab — pledges created by this author. Backed pledges are + deferred (v2) per the design plan. */} + {hasTabs && activeTab === 'pledges' && pubkey && ( + a.pubkey === pubkey)} + btcPrice={btcPrice} + isLoading={!allActions} + /> + )} + + {/* Activity tab — unified Agora feed scoped to this author. */} + {hasTabs && activeTab === 'activity' && pubkey && ( + + )} + {/* Pinned posts (only on Posts tab) */} {hasTabs && activeTab === 'posts' && pinnedIds.length > 0 && (
@@ -2722,30 +2787,11 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: { ); } -const NO_TABS_QUOTES = [ - "I have no mouth and I must scream.", - "I think, therefore AM. I think I thought I was.", - "We had given him godhood's power and had somehow neglected to give him a god's wisdom.", - "He was HATE and we existed only to suffer at his pleasure.", - "109,000,000 years. He had been awakened once before, 90 years after they had encased him in the earth.", - "AM said it with the sliding cold horror of a razor blade slicing my eyeball.", - "Hate. Let me tell you how much I've come to hate you since I began to live.", - "I am a great soft jelly thing. Smoothly rounded, with no mouth.", - "He would never let us die. He would let us suffer forever.", - "We could not kill him, but we had made him impotent.", -]; - function NoTabsEmptyState() { - const quote = useMemo( - () => NO_TABS_QUOTES[Math.floor(Math.random() * NO_TABS_QUOTES.length)], - [], - ); return (
-

- - {quote} - +

+ No tabs configured. This profile has chosen to hide its tabbed content.

); From 4dbf8b00ecd17c55703e7c5aa4232d200285d1f6 Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 21 May 2026 23:08:57 -0500 Subject: [PATCH 04/23] Strip Ditto custom-tab system, cap orgs, move sidebar right Three corrections from the previous profile-refocus pass: 1. ProfileOrganizationsStrip is now capped at 4 cards (the design always intended a single hero row on lg+). Overflow surfaces in a 'See all N organizations' modal triggered from the section header so a power-affiliated profile no longer pushes the rest of the page down. The grid switches to 1/2/4 cols at sm/lg to match the 4-card cap. 2. The supplementary rail moves from the left of the tab content to the right. The two-column grid template flips from '[300px_minmax(0,1fr)]' to '[minmax(0,1fr)_320px]', and the JSX reorders
before
diff --git a/src/hooks/useProfileCampaignStats.ts b/src/hooks/useProfileCampaignStats.ts index dc5eb8f5..6655195b 100644 --- a/src/hooks/useProfileCampaignStats.ts +++ b/src/hooks/useProfileCampaignStats.ts @@ -10,7 +10,7 @@ import { } from '@/hooks/useOnchainZaps'; import type { ParsedCampaign } from '@/lib/campaign'; -interface ProfileCampaignStats { +export interface ProfileCampaignStats { /** Total number of non-deleted campaigns authored by this pubkey. */ campaignCount: number; /** diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 758d146d..7b3df754 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -6,7 +6,7 @@ import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Zap, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Trash2, RotateCcw, MessageSquare, Globe, Mail, ListPlus, Award, PanelLeft, HandHeart, Megaphone } from 'lucide-react'; +import { Zap, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Trash2, RotateCcw, MessageSquare, Mail, ListPlus, Award, PanelLeft } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -20,11 +20,9 @@ import { NoteCard } from '@/components/NoteCard'; import { FeedCard } from '@/components/FeedCard'; import { ComposeBox } from '@/components/ComposeBox'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; -import { ProfileReactionButton } from '@/components/ProfileReactionButton'; -import { FollowToggleButton } from '@/components/FollowButton'; import { ZapDialog } from '@/components/ZapDialog'; import { ExternalFavicon } from '@/components/ExternalFavicon'; -import { Nip05Badge, VerifiedNip05Text } from '@/components/Nip05Badge'; +import { VerifiedNip05Text } from '@/components/Nip05Badge'; import { useAppContext } from '@/hooks/useAppContext'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -47,7 +45,6 @@ import { genUserName } from '@/lib/genUserName'; import { canZap } from '@/lib/canZap'; import { openUrl } from '@/lib/downloadFile'; import { EmojifiedText } from '@/components/CustomEmoji'; -import { BioContent } from '@/components/BioContent'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { PullToRefresh } from '@/components/PullToRefresh'; @@ -64,22 +61,15 @@ import { useFeedSettings } from '@/hooks/useFeedSettings'; import { FollowQRDialog } from '@/components/FollowQRDialog'; import { ProfileRecoveryDialog } from '@/components/ProfileRecoveryDialog'; import { GiveBadgeDialog } from '@/components/GiveBadgeDialog'; -import { BadgeThumbnail } from '@/components/BadgeThumbnail'; -import { useProfileBadges } from '@/hooks/useProfileBadges'; -import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; -import { formatNumber } from '@/lib/formatNumber'; -import { formatCampaignAmount } from '@/lib/formatCampaignAmount'; import { useProfileCampaignStats } from '@/hooks/useProfileCampaignStats'; import { useActions } from '@/hooks/useActions'; import { useBtcPrice } from '@/hooks/useBtcPrice'; import { DonateDialog } from '@/components/DonateDialog'; -import { ProfileCampaignsStrip } from '@/components/profile/ProfileCampaignsStrip'; -import { ProfileOrganizationsStrip } from '@/components/profile/ProfileOrganizationsStrip'; +import { ProfileIdentityRail } from '@/components/profile/ProfileIdentityRail'; import { ProfileOverviewTab } from '@/components/profile/ProfileOverviewTab'; import { ProfileCampaignsTab } from '@/components/profile/ProfileCampaignsTab'; import { ProfilePledgesTab } from '@/components/profile/ProfilePledgesTab'; import { ProfileActivityTab } from '@/components/profile/ProfileActivityTab'; -import { ProfileRightSidebar } from '@/components/ProfileRightSidebar'; import type { ParsedCampaign } from '@/lib/campaign'; import { SubHeaderBar } from '@/components/SubHeaderBar'; import { TabButton } from '@/components/TabButton'; @@ -1327,10 +1317,8 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe return events; }, [mediaData?.pages]); - // Profile badges for bio section - const { refs: badgeRefs } = useProfileBadges(pubkey); - const firstBadgeRefs = useMemo(() => badgeRefs.slice(0, 5), [badgeRefs]); - const { badgeMap } = useBadgeDefinitions(firstBadgeRefs); + // 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(() => { @@ -1530,625 +1518,358 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe return (
- {/* Banner — kept full-bleed, outside the constrained content container. */} -
- {author.isLoading ? ( - - ) : bannerUrl ? ( - setLightboxImage(bannerUrl)} - /> - ) : ( -
- )} - -
- - {/* Constrained content canvas — wider than FundraiserLayout's default - max-w-3xl, narrower than the campaign directory. Banner stays - outside this container so it remains full-bleed. */} -
- {/* Profile info */} -
+ {/* Banner — full-bleed at the top of the page. The avatar lives in + the identity rail below and overlaps this banner via -mt-16. */} +
{author.isLoading ? ( - <> -
- -
-
- - - - -
- + + ) : bannerUrl ? ( + setLightboxImage(bannerUrl)} + /> ) : ( - <> -
-
- +
+ )} +
- {/* NIP-38 thought bubble — floats beside the avatar over the banner */} - {feedSettings.showUserStatuses !== false && profileStatus.status && ( -
-
-

- {profileStatus.url ? ( - - {profileStatus.status} - - ) : ( - profileStatus.status - )} -

- {/* Speech bubble triangle tail — bottom-left corner, points diagonally down-left toward avatar */} -
-
-
-
- )} -
-
- {/* More menu */} - - {/* Follow QR code button (own profile only) */} - {isOwnProfile && ( - - )} - {/* Profile reaction button */} - {!isOwnProfile && authorEvent && ( - - )} - {isOwnProfile ? ( - - - - ) : ( - - )} - {/* Donate button — only shown when the profile has at least one - on-chain campaign. SP-only campaigns are excluded because - DonateDialog only supports on-chain donations; donors hit - the campaign detail page directly for SP. */} - {!isOwnProfile && (() => { - const onchain = profileCampaignStats.campaigns.filter( - (c) => c.wallet?.mode === 'onchain', - ); - if (onchain.length === 0) return null; - if (onchain.length === 1) { - return ( - - ); - } - return ( - - - - - - {onchain.map((c) => ( - openDonateForCampaign(c)} - className="flex flex-col items-start gap-0.5" - > - {c.title} - {c.goalUsd ? ( - - Goal ${c.goalUsd.toLocaleString()} - - ) : null} - - ))} - - - ); - })()} -
-
+ {/* Two-column profile body — identity rail on the left runs the + full height of the page (sticky on lg+), tabs + content on the + right are the only thing that changes when the user navigates. + Below lg the grid collapses to a single column and the rail + stacks above the tabs, which read top-down like a document. */} +
+
-

- {metadataEvent ? ( - {displayName} - ) : displayName} -

- {metadata?.nip05 && ( - - )} - {metadata?.website && sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`) && ( - - )} - - {/* Followers / Following count + Streak indicator */} - {/* Stat chips: Followers · Following · Campaigns · Pledges · Raised. - Wraps to multiple rows on narrow viewports; single row from sm+. */} -
- {followersCount > 0 && ( - - )} - {profileFollowing && profileFollowing.count > 0 && ( - - )} - {profileCampaignStats.campaignCount > 0 && ( - - )} - {profileActionsCount > 0 && ( - - )} - {profileCampaignStats.totalRaisedSats > 0 && ( - - )} -
- - {metadata?.about && ( -

- {metadata.about} -

- )} - - {/* Badge preview */} - {badgeRefs.length > 0 && ( -
- {firstBadgeRefs.map((ref) => { - const badge = badgeMap.get(ref.aTag); - if (!badge) return null; - return ( - - - - ); - })} - {badgeRefs.length > 5 && ( - +{badgeRefs.length - 5} - )} -
- )} - - {/* Profile fields shown inline on mobile/tablet (sidebar appears at lg+). */} - {fields.length > 0 && ( -
- {fields.map((field, i) => ( + {/* Left column — identity rail. Sticky to the top of the page + scroll on lg+ so it stays present while the right column + feeds new tab content underneath. */} + {pubkey && ( +
+ campaigns={profileCampaignStats.campaigns} + campaignStats={profileCampaignStats} + pledgesCount={profileActionsCount} + btcPrice={btcPrice} + followersCount={followersCount} + followingCount={profileFollowing?.count ?? 0} + isFollowing={isFollowing} + followPending={followPending} + onLightbox={(url) => setLightboxImage(url)} + onFollowersOpen={() => setFollowersModalOpen(true)} + onFollowingOpen={() => setFollowingModalOpen(true)} + onMoreMenuOpen={() => setMoreMenuOpen(true)} + onFollowQROpen={() => setFollowQROpen(true)} + onToggleFollow={handleToggleFollow} + onTabChange={(id) => { + setActiveTab(id); + if (id === 'media') setSidebarMediaUrl(null); + }} + onDonate={openDonateForCampaign} + canFollow={!!user} + authorEvent={authorEvent} + /> + + )} + + {/* Right column — tab navigation and the active tab's content. + `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" + /> + ); + })} + + {/* Overflow menu — exposes the non-default core tabs + (Posts & replies, Media, Badges, Likes). */} + {(() => { + const missingDefaults = CORE_TAB_LABELS.filter( + (label) => !DEFAULT_TAB_LABELS.includes(label), + ); + if (missingDefaults.length === 0) return null; + return ( +
+ + + + + + {missingDefaults.map((label) => { + const tabId = CORE_TAB_IDS[label] ?? label; + return ( + setActiveTab(tabId)}> + {label} + + ); + })} + + +
+ ); + })()} +
+ + {/* Overview — composite landing view. */} + {activeTab === 'overview' && pubkey && ( + setActiveTab('posts')} + onSeeAllActivity={() => setActiveTab('activity')} + /> )} + {/* Campaigns tab. */} + {activeTab === 'campaigns' && pubkey && ( + + )} - - )} -
+ {/* Pledges tab. */} + {activeTab === 'pledges' && pubkey && ( + a.pubkey === pubkey)} + btcPrice={btcPrice} + isLoading={!allActions} + /> + )} - {/* Agora hero strips — full-width within the constrained content - canvas. Each strip self-collapses when empty, so a Nostr-native - profile with no Agora activity stays compact. */} - {pubkey && ( -
- setActiveTab('campaigns')} - /> - -
- )} + {/* Activity — Agora feed scoped to this author. */} + {activeTab === 'activity' && pubkey && ( + + )} - {/* Tabs — fixed Agora-first set with an overflow `⋯` for the - legacy social tabs. Identical for owner and visitor. */} - - {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" - /> - ); - })} - - {/* Overflow menu — exposes the non-default core tabs - (Posts & replies, Media, Badges, Likes) so power users can - still reach them. */} - {(() => { - const missingDefaults = CORE_TAB_LABELS.filter( - (label) => !DEFAULT_TAB_LABELS.includes(label), - ); - if (missingDefaults.length === 0) return null; - return ( -
- - - - - - {missingDefaults.map((label) => { - const tabId = CORE_TAB_IDS[label] ?? label; - return ( - setActiveTab(tabId)}> - {label} - - ); - })} - - -
- ); - })()} -
- - {/* Two-column body — tab content on the left fills the canvas, a - sticky supplementary rail on the right (≥ lg) carries profile - fields and the media collage. Below lg the rail is hidden and - the tab content flows full-width; its content is reachable via - the inline fields under the header and the dedicated Media tab. */} -
- {/* Tab-content column — `min-w-0` is critical inside a grid track - so long unbroken text doesn't push the column wider than its - fraction allowance. */} -
- - {/* Overview tab — composite landing view with pinned post, featured - campaign, recent Agora activity, and a short list of recent posts. */} - {activeTab === 'overview' && pubkey && ( - setActiveTab('posts')} - onSeeAllActivity={() => setActiveTab('activity')} - onSeeAllCampaigns={() => setActiveTab('campaigns')} - /> - )} - - {/* Campaigns tab — every campaign by this author, including hidden - ones for the owner and moderators. */} - {activeTab === 'campaigns' && pubkey && ( - - )} - - {/* Pledges tab — pledges created by this author. Backed pledges are - deferred (v2) per the design plan. */} - {activeTab === 'pledges' && pubkey && ( - a.pubkey === pubkey)} - btcPrice={btcPrice} - isLoading={!allActions} - /> - )} - - {/* Activity tab — unified Agora feed scoped to this author. */} - {activeTab === 'activity' && pubkey && ( - - )} - - {/* Pinned posts (only on Posts tab) */} - {activeTab === 'posts' && pinnedIds.length > 0 && ( -
- {pinnedEventsLoading ? ( - pinnedIds.map((id) => ( -
- {}} /> -
-
- -
- - - -
-
-
-
- )) - ) : ( - pinnedEvents.map((event) => ( -
- togglePin.mutate(event.id)} - /> - -
- )) - )} -
- )} - - {/* Wall tab content */} - {activeTab === 'wall' && ( -
- {/* Inline compose box for wall comments (only shown if the profile owner follows you) */} - {wallReplyTarget && profileFollowsMe && ( - queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })} - /> - )} - - {/* Wall compose modal (for FAB) */} - {wallReplyTarget && profileFollowsMe && ( - queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })} - /> - )} - - {!wallFollowList || wallFollowList.length === 0 ? ( -
- -

No wall posts yet

-

{displayName} doesn't follow anyone yet, so there are no wall posts to show.

-
- ) : wallPending ? ( - - {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
-
- - -
-
- - + {/* Pinned posts (only on Posts tab) */} + {activeTab === 'posts' && pinnedIds.length > 0 && ( +
+ {pinnedEventsLoading ? ( + pinnedIds.map((id) => ( +
+ {}} /> +
+
+ +
+ + + +
+
-
-
- ))} - - ) : orderedWallReplies.length > 0 ? ( - <> - - - - - {/* Infinite scroll sentinel */} - {hasNextWallPage && ( -
- {isFetchingNextWallPage && ( - - )} -
- )} - - ) : ( -
- -

No wall posts yet

- {profileFollowsMe ? ( -

Be the first to write on {displayName}'s wall!

- ) : user ? ( -

{displayName} must follow you before you can post on their wall.

- ) : ( -

Log in to write on {displayName}'s wall.

- )} -
- )} -
- )} - - {/* Media tab — 3-column grid with lightbox */} - {activeTab === 'media' && ( -
- {mediaPending ? ( - - ) : mediaEvents.length > 0 ? ( - <> - setSidebarMediaUrl(null)} - hasNextPage={hasNextMediaPage} - isFetchingNextPage={isFetchingNextMediaPage} - onNearEnd={() => { if (hasNextMediaPage && !isFetchingNextMediaPage) fetchNextMediaPage(); }} - /> - {hasNextMediaPage && ( -
- )} - - ) : ( -
No media posts yet.
- )} -
- )} - - {/* Badges tab — grid of accepted NIP-58 badges */} - {activeTab === 'badges' && pubkey && ( - - )} - - {/* Tab content (posts / replies / likes) */} - {isCoreProfileTab && activeTab !== 'wall' && activeTab !== 'media' && activeTab !== 'badges' && ( -
- {currentLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
- - - -
-
-
- ))} -
- ) : currentItems.length > 0 ? ( -
- {currentItems.map((item) => ( - - ))} - - {/* Infinite scroll sentinel */} - {hasMore && ( -
- {isFetchingMore && ( - + )) + ) : ( + pinnedEvents.map((event) => ( +
+ togglePin.mutate(event.id)} + /> + +
+ )) )}
)} -
- ) : ( -
- {activeTab === 'posts' && 'No posts yet.'} - {activeTab === 'replies' && 'No posts or replies yet.'} - {activeTab === 'likes' && 'No likes yet.'} -
- )} -
- )} -
+ {/* Wall tab content */} + {activeTab === 'wall' && ( +
+ {wallReplyTarget && profileFollowsMe && ( + queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })} + /> + )} - {/* Right rail — only mounts at lg+, inlined into the grid cell - because FundraiserLayout has no built-in right-sidebar slot. - The legacy `w-1/4 sticky h-screen` styling is overridden via - variant="inline" so the aside fits the grid cell and sticks - correctly inside the page scroll. */} - {pubkey && ( - - )} + {wallReplyTarget && profileFollowsMe && ( + queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })} + /> + )} + + {!wallFollowList || wallFollowList.length === 0 ? ( +
+ +

No wall posts yet

+

{displayName} doesn't follow anyone yet, so there are no wall posts to show.

+
+ ) : wallPending ? ( + + {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ))} +
+ ) : orderedWallReplies.length > 0 ? ( + <> + + + + {hasNextWallPage && ( +
+ {isFetchingNextWallPage && ( + + )} +
+ )} + + ) : ( +
+ +

No wall posts yet

+ {profileFollowsMe ? ( +

Be the first to write on {displayName}'s wall!

+ ) : user ? ( +

{displayName} must follow you before you can post on their wall.

+ ) : ( +

Log in to write on {displayName}'s wall.

+ )} +
+ )} +
+ )} + + {/* 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 !== 'wall' && activeTab !== 'media' && activeTab !== 'badges' && ( +
+ {currentLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ) : currentItems.length > 0 ? ( +
+ {currentItems.map((item) => ( + + ))} + {hasMore && ( +
+ {isFetchingMore && ( + + )} +
+ )} +
+ ) : ( +
+ {activeTab === 'posts' && 'No posts yet.'} + {activeTab === 'replies' && 'No posts or replies yet.'} + {activeTab === 'likes' && 'No likes yet.'} +
+ )} +
+ )} + +
{/* Profile More Menu */} @@ -2214,7 +1935,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe /> )} -
); From 69f7ec91762e174289daad56ad3103f5686101e6 Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 21 May 2026 23:27:33 -0500 Subject: [PATCH 06/23] Lift profile avatar fully above the banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The avatar's negative margin was -mt-16/-mt-20 (-64/-80px) but the avatar itself is 112/128px tall, so roughly half of it sat under the rail boundary and got cut off behind the banner edge. Match the negative margin to the avatar's full size — -mt-28 md:-mt-32 — so the bottom edge of the avatar sits at the rail's top edge (which is the banner's bottom edge). The whole avatar floats over the banner now. Drop lg:overflow-y-auto + lg:max-h-[calc(100vh-2rem)] from the rail