diff --git a/app/activity/page.tsx b/app/activity/page.tsx
index 4cb34ccd5..00bc7dfae 100644
--- a/app/activity/page.tsx
+++ b/app/activity/page.tsx
@@ -8,7 +8,7 @@ import { PageLayout } from '@/app/layouts/PageLayout';
import { HeroHeader } from '@/components/ui/HeroHeader';
import { PillTabs } from '@/components/ui/PillTabs';
import { ActivityCardFull } from '@/components/Activity/ActivityCardFull';
-import { ActivityCardSkeletonList } from '@/components/Activity/ActivityCardSkeleton';
+import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton';
import { useActivityFeed, ActivityTab } from '@/hooks/useActivityFeed';
import { ActivityScope } from '@/services/activity.service';
import { GrantService } from '@/services/grant.service';
@@ -106,7 +106,8 @@ export default function ActivityPage() {
))}
- {(isLoading || isLoadingMore) && }
+ {(isLoading || isLoadingMore) &&
+ [...Array(6)].map((_, i) => )}
{!isLoading && !isLoadingMore && entries.length === 0 && (
diff --git a/app/fund/activity/FundActivityPageContent.tsx b/app/fund/activity/FundActivityPageContent.tsx
new file mode 100644
index 000000000..cbc6be4a6
--- /dev/null
+++ b/app/fund/activity/FundActivityPageContent.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useInView } from 'react-intersection-observer';
+import { ActivityCardFull } from '@/components/Activity/ActivityCardFull';
+import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton';
+import { useActivityFeed } from '@/hooks/useActivityFeed';
+
+export function FundActivityPageContent() {
+ const { entries, isLoading, isLoadingMore, hasMore, loadMore } = useActivityFeed({});
+
+ const { ref: sentinelRef } = useInView({
+ threshold: 0,
+ rootMargin: '200px',
+ onChange: (inView) => {
+ if (inView && hasMore && !isLoading && !isLoadingMore) {
+ loadMore();
+ }
+ },
+ });
+
+ return (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ {(isLoading || isLoadingMore) &&
+ [...Array(6)].map((_, i) =>
)}
+
+ {!isLoading && !isLoadingMore && entries.length === 0 && (
+
+ )}
+
+ {!isLoading && !isLoadingMore && hasMore &&
}
+
+ );
+}
diff --git a/app/fund/activity/loading.tsx b/app/fund/activity/loading.tsx
new file mode 100644
index 000000000..3a8fc0ec6
--- /dev/null
+++ b/app/fund/activity/loading.tsx
@@ -0,0 +1,16 @@
+import { PageLayout } from '@/app/layouts/PageLayout';
+import { FundingBannerSkeleton } from '@/components/Funding/FundingBannerSkeleton';
+import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton';
+import { FundSidebar } from '@/components/Funding/FundSidebar';
+
+export default function FundActivityLoading() {
+ return (
+
} rightSidebar={
}>
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/fund/activity/page.tsx b/app/fund/activity/page.tsx
new file mode 100644
index 000000000..3e62a9f9b
--- /dev/null
+++ b/app/fund/activity/page.tsx
@@ -0,0 +1,39 @@
+import { Metadata } from 'next';
+import { buildOpenGraphMetadata } from '@/lib/metadata';
+import { PageLayout } from '@/app/layouts/PageLayout';
+import { HeroHeader } from '@/components/ui/HeroHeader';
+import { MarketplaceCards } from '@/components/Funding/MarketplaceCards';
+import { FundingHeroPanel } from '@/components/Funding/FundingHeroPanel';
+import { OpenFundingOpportunityCTA } from '../OpenFundingOpportunityCTA';
+import { FundActivityPageContent } from './FundActivityPageContent';
+import { FundSidebar } from '@/components/Funding/FundSidebar';
+
+export const metadata: Metadata = buildOpenGraphMetadata({
+ title: 'Activity',
+ description: 'Recent activity across funding opportunities and proposals.',
+ url: '/fund/activity',
+});
+
+export default async function FundActivityPage() {
+ return (
+
+ Recent activity across funding opportunities and proposals.
+
+ }
+ cta={} />}
+ alignTop
+ >
+
+
+ }
+ rightSidebar={}
+ >
+
+
+ );
+}
diff --git a/app/fund/loading.tsx b/app/fund/loading.tsx
index 14d6d1fb6..6045bad30 100644
--- a/app/fund/loading.tsx
+++ b/app/fund/loading.tsx
@@ -1,20 +1,16 @@
import { PageLayout } from '@/app/layouts/PageLayout';
-import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton';
import { FundingBannerSkeleton } from '@/components/Funding/FundingBannerSkeleton';
import { FeedItemSkeleton } from '@/components/Feed/FeedItemSkeleton';
+import { FundSidebar } from '@/components/Funding/FundSidebar';
export default function FundLoading() {
return (
-
}
- rightSidebar={
}
- >
+
} rightSidebar={
}>
- {/* Feed skeletons */}
{Array.from({ length: 3 }, (_, i) => (
diff --git a/app/fund/page.tsx b/app/fund/page.tsx
index a5892037b..bcc3496b1 100644
--- a/app/fund/page.tsx
+++ b/app/fund/page.tsx
@@ -1,17 +1,15 @@
-import { Suspense } from 'react';
import { Metadata } from 'next';
import { buildOpenGraphMetadata } from '@/lib/metadata';
import { PageLayout } from '@/app/layouts/PageLayout';
-import { FundingSidebarServer } from '@/components/Funding/FundingSidebarServer';
-import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton';
import { HeroHeader } from '@/components/ui/HeroHeader';
import { FundGrantsPageContent } from './FundGrantsPageContent';
import { MarketplaceCards } from '@/components/Funding/MarketplaceCards';
import { FundingHeroPanel } from '@/components/Funding/FundingHeroPanel';
import { OpenFundingOpportunityCTA } from './OpenFundingOpportunityCTA';
+import { FundSidebar } from '@/components/Funding/FundSidebar';
export const metadata: Metadata = buildOpenGraphMetadata({
- title: 'Funding Opportunities',
+ title: 'Request for Proposals',
description: 'Apply for funding opportunities via proposals.',
url: '/fund',
});
@@ -21,7 +19,7 @@ export default async function FundPage() {
Apply for funding opportunities via proposals.
@@ -33,11 +31,7 @@ export default async function FundPage() {
}
- rightSidebar={
- }>
-
-
- }
+ rightSidebar={}
>
diff --git a/app/fund/proposals/loading.tsx b/app/fund/proposals/loading.tsx
index aa48bffd6..a26378af3 100644
--- a/app/fund/proposals/loading.tsx
+++ b/app/fund/proposals/loading.tsx
@@ -1,14 +1,11 @@
import { PageLayout } from '@/app/layouts/PageLayout';
-import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton';
import { FundingBannerSkeleton } from '@/components/Funding/FundingBannerSkeleton';
import { FeedItemSkeleton } from '@/components/Feed/FeedItemSkeleton';
+import { FundSidebar } from '@/components/Funding/FundSidebar';
export default function FundProposalsLoading() {
return (
- }
- rightSidebar={}
- >
+ } rightSidebar={}>
diff --git a/app/fund/proposals/page.tsx b/app/fund/proposals/page.tsx
index 7629cee78..8cddf1eb7 100644
--- a/app/fund/proposals/page.tsx
+++ b/app/fund/proposals/page.tsx
@@ -1,4 +1,3 @@
-import { Suspense } from 'react';
import { Metadata } from 'next';
import Link from 'next/link';
import { ArrowUpFromLine } from 'lucide-react';
@@ -6,13 +5,12 @@ import { buildOpenGraphMetadata } from '@/lib/metadata';
import { PageLayout } from '@/app/layouts/PageLayout';
import { ProposalFeed } from '@/components/Funding/ProposalFeed';
import { ProposalSortAndFilters } from '@/components/Funding/ProposalSortAndFilters';
-import { FundingSidebarServer } from '@/components/Funding/FundingSidebarServer';
-import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton';
import { HeroHeader } from '@/components/ui/HeroHeader';
import { Button } from '@/components/ui/Button';
import { SubmitProposalTooltip } from '@/components/tooltips/SubmitProposalTooltip';
import { MarketplaceCards } from '@/components/Funding/MarketplaceCards';
import { FundingHeroPanel } from '@/components/Funding/FundingHeroPanel';
+import { FundSidebar } from '@/components/Funding/FundSidebar';
export const metadata: Metadata = buildOpenGraphMetadata({
title: 'Proposals',
@@ -54,11 +52,7 @@ export default async function FundProposalsPage() {
}
- rightSidebar={
-
}>
-
-
- }
+ rightSidebar={
}
>
diff --git a/app/grant/[id]/[slug]/page.tsx b/app/grant/[id]/[slug]/page.tsx
index f8150136e..a6e010542 100644
--- a/app/grant/[id]/[slug]/page.tsx
+++ b/app/grant/[id]/[slug]/page.tsx
@@ -41,12 +41,7 @@ export default async function GrantSlugPage({ params }: Props) {
const grantId = grant?.id ?? undefined;
return (
-
+
{grant?.description && }
diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx
index 961c236ac..c8acf0c63 100644
--- a/app/layouts/topbar/pageRoutes.tsx
+++ b/app/layouts/topbar/pageRoutes.tsx
@@ -28,6 +28,7 @@ export const ROOT_NAVIGATION_PATHS = new Set([
'/earn',
'/fund',
'/fund/proposals',
+ '/fund/activity',
'/journal',
'/notebook',
'/browse',
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 3d6ef82c3..9a415f764 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -71,6 +71,7 @@ const STATIC_ROUTES: MetadataRoute.Sitemap = [
{ url: `${SITE_URL}/latest`, changeFrequency: 'hourly', priority: 0.8 },
{ url: `${SITE_URL}/fund`, changeFrequency: 'daily', priority: 0.8 },
{ url: `${SITE_URL}/fund/proposals`, changeFrequency: 'daily', priority: 0.7 },
+ { url: `${SITE_URL}/fund/activity`, changeFrequency: 'hourly', priority: 0.7 },
{ url: `${SITE_URL}/earn`, changeFrequency: 'daily', priority: 0.8 },
{ url: `${SITE_URL}/grants`, changeFrequency: 'daily', priority: 0.7 },
{ url: `${SITE_URL}/journal`, changeFrequency: 'daily', priority: 0.7 },
diff --git a/components/Activity/ActivityCardFull.tsx b/components/Activity/ActivityCardFull.tsx
index 48a0d1da8..3583df8a9 100644
--- a/components/Activity/ActivityCardFull.tsx
+++ b/components/Activity/ActivityCardFull.tsx
@@ -1,24 +1,21 @@
'use client';
import { FC, useState } from 'react';
-import Link from 'next/link';
-import { Star, ChevronDown, ChevronUp } from 'lucide-react';
+import { useRouter } from 'next/navigation';
+import { ArrowRight } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
+import { Button } from '@/components/ui/Button';
import { CommentReadOnly } from '@/components/Comment/CommentReadOnly';
-import { ContributionAmount } from './ContributionAmount';
-import { FeedEntryIcon } from './FeedEntryIcon';
-import { GrantFundingAmount } from './GrantFundingAmount';
-import {
- getActionIcon,
- getActionLabel,
- getCommentPreview,
- getContribution,
- getEntryMeta,
- getGrantAmount,
- getReviewScore,
-} from './lib/feedEntryAdapters';
-import { formatTimeAgo } from '@/utils/date';
+import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal';
+import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
+import { useExchangeRate } from '@/contexts/ExchangeRateContext';
+import { useShareModalContext } from '@/contexts/ShareContext';
+import { ActivityCardHeader } from './ActivityCardHeader';
+import { WorkCardActions } from './WorkCardActions';
+import { WorkPreviewCard } from './WorkPreviewCard';
+import { getActivityHeaderMessage, getCommentPreview } from './lib/feedEntryAdapters';
+import { getActivityWorkContext, getWorkCardPresentation } from './lib/activityWorkContext';
import type { FeedEntry } from '@/types/feed';
interface ActivityCardFullProps {
@@ -26,96 +23,125 @@ interface ActivityCardFullProps {
}
export const ActivityCardFull: FC = ({ entry }) => {
- const { title, author, href } = getEntryMeta(entry);
- const [reviewExpanded, setReviewExpanded] = useState(false);
+ const work = getActivityWorkContext(entry);
+ const router = useRouter();
+ const { showUSD } = useCurrencyPreference();
+ const { exchangeRate } = useExchangeRate();
+ const { showShareModal } = useShareModalContext();
+ const [isContributeModalOpen, setIsContributeModalOpen] = useState(false);
- if (!title) return null;
+ if (!work) return null;
- const actionLabel = getActionLabel(entry);
- const actionIcon = getActionIcon(entry);
- const reviewScore = getReviewScore(entry);
- const grantAmount = getGrantAmount(entry);
- const contribution = getContribution(entry);
+ const message = getActivityHeaderMessage(entry);
const commentPreview = getCommentPreview(entry);
+ const presentation = getWorkCardPresentation(entry, work, {
+ showUSD,
+ exchangeRate,
+ isReview: commentPreview?.isReview,
+ });
- const titleEl = href ? (
-
- {title}
-
- ) : (
- {title}
- );
+ const showComment = presentation.showComment && !!commentPreview;
+ const voteCount = entry.metrics?.adjustedScore ?? entry.metrics?.votes ?? 0;
+ const isReviewOfProposal = !!commentPreview?.isReview && work.documentType === 'preregistration';
+
+ const handleFundClick = () => {
+ setIsContributeModalOpen(true);
+ };
+
+ const handleContributeSuccess = () => {
+ setIsContributeModalOpen(false);
+ showShareModal({
+ url: window.location.href,
+ docTitle: work.title,
+ action: 'USER_FUNDED_PROPOSAL',
+ });
+ router.refresh();
+ };
+
+ const action = (() => {
+ const cta = presentation.cta;
+ if (!cta) return undefined;
+
+ return (
+
+ );
+ })();
return (
-
-
-
-
-
{author?.fullName || 'Unknown'}
-
{actionLabel}
-
- {reviewScore != null && (
-
-
- {reviewScore.toFixed(1)}
-
- )}
- {grantAmount &&
}
- {contribution && (
-
- )}
+
+
- {commentPreview && !commentPreview.isReview && (
-
-
-
- )}
+
+
- {commentPreview && commentPreview.isReview && (
-
-
- {reviewExpanded && (
+ {showComment && commentPreview && (
)}
+
+
+
+ }
+ />
+
- )}
+
-
- {formatTimeAgo(entry.timestamp)}
-
-
+ {work.fundraise && (
+
setIsContributeModalOpen(false)}
+ onContributeSuccess={handleContributeSuccess}
+ fundraise={work.fundraise}
+ proposalTitle={work.title}
+ />
+ )}
+
);
};
diff --git a/components/Activity/ActivityCardHeader.tsx b/components/Activity/ActivityCardHeader.tsx
new file mode 100644
index 000000000..98bc2b8e3
--- /dev/null
+++ b/components/Activity/ActivityCardHeader.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { FC } from 'react';
+import { ActivityHeaderActionText } from './ActivityHeaderActionText';
+import { BountyAmount } from './BountyAmount';
+import { ContributionAmount } from './ContributionAmount';
+import { FeedEntryIcon } from './FeedEntryIcon';
+import { GrantFundingAmount } from './GrantFundingAmount';
+import { ReviewScoreStars } from './ReviewScoreStars';
+import {
+ getActionIcon,
+ getActivityHeaderMessage,
+ getContribution,
+ getGrantAmount,
+ getReviewEarning,
+ getReviewScore,
+} from './lib/feedEntryAdapters';
+import { getActivityBounty } from './lib/activityWorkContext';
+import { formatTimeAgo } from '@/utils/date';
+import { Tooltip } from '@/components/ui/Tooltip';
+import type { FeedEntry } from '@/types/feed';
+
+interface ActivityCardHeaderProps {
+ entry: FeedEntry;
+}
+
+export const ActivityCardHeader: FC = ({ entry }) => {
+ const message = getActivityHeaderMessage(entry);
+ const actionIcon = getActionIcon(entry);
+ const reviewScore = getReviewScore(entry);
+ const reviewEarning = getReviewEarning(entry);
+ const grantAmount = getGrantAmount(entry);
+ const contribution = getContribution(entry);
+ const bounty = entry.activityContext === 'bounty_opened' ? getActivityBounty(entry) : undefined;
+
+ const hasAmount = Boolean(
+ grantAmount || contribution || reviewEarning || bounty || reviewScore != null
+ );
+
+ return (
+
+
+
+ {grantAmount && (
+ <>
+ {' '}
+
+ >
+ )}
+ {contribution && (
+ <>
+ {' '}
+
+ >
+ )}
+ {reviewEarning && (
+ <>
+ {' '}
+
+ >
+ )}
+ {bounty && (
+ <>
+ {' '}
+
+ >
+ )}
+ {reviewScore != null && reviewScore > 0 && (
+ <>
+ {' '}
+
+ >
+ )}
+
+
+
+
+
+ {formatTimeAgo(entry.timestamp)}
+
+
+
+ );
+};
diff --git a/components/Activity/ActivityCardSkeleton.tsx b/components/Activity/ActivityCardSkeleton.tsx
index ff83e5d26..5f02667f7 100644
--- a/components/Activity/ActivityCardSkeleton.tsx
+++ b/components/Activity/ActivityCardSkeleton.tsx
@@ -1,41 +1,31 @@
'use client';
import { FC } from 'react';
-import { cn } from '@/utils/styles';
-const TITLE_WIDTHS = ['w-3/4', 'w-2/3', 'w-5/6'] as const;
+export const ActivityCardSkeleton: FC = () => (
+
+
+
-interface ActivityCardSkeletonProps {
- titleWidth?: (typeof TITLE_WIDTHS)[number];
-}
+
+
-export const ActivityCardSkeleton: FC
= ({ titleWidth = 'w-2/3' }) => (
-
-);
-
-interface ActivityCardSkeletonListProps {
- count?: number;
- className?: string;
-}
-
-export const ActivityCardSkeletonList: FC = ({
- count = 15,
- className,
-}) => (
-
- {Array.from({ length: count }, (_, i) => (
-
- ))}
);
diff --git a/components/Activity/ActivityHeaderActionText.tsx b/components/Activity/ActivityHeaderActionText.tsx
new file mode 100644
index 000000000..58d0bd51f
--- /dev/null
+++ b/components/Activity/ActivityHeaderActionText.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { FC } from 'react';
+import Link from 'next/link';
+import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
+import type { ActivityHeaderMessage } from './lib/feedEntryAdapters';
+
+interface ActivityHeaderActionTextProps {
+ message: ActivityHeaderMessage;
+ className?: string;
+}
+
+export const ActivityHeaderActionText: FC = ({
+ message,
+ className,
+}) => {
+ const { actor, verb, target } = message;
+
+ return (
+
+
+
+ {actor.fullName || 'Unknown'}
+
+
+ {verb}
+ {target && (
+ <>
+ {' '}
+
+
+ {target.author.fullName || 'Unknown'}
+
+
+ {target.suffix && {target.suffix}}
+ >
+ )}
+
+ );
+};
diff --git a/components/Activity/AmountBadge.tsx b/components/Activity/AmountBadge.tsx
new file mode 100644
index 000000000..b9297fb49
--- /dev/null
+++ b/components/Activity/AmountBadge.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { FC, ReactNode } from 'react';
+import { cn } from '@/utils/styles';
+
+const TONES = {
+ green: 'bg-green-100 text-green-800',
+ orange: 'bg-orange-100 text-orange-700',
+} as const;
+
+interface AmountBadgeProps {
+ tone?: keyof typeof TONES;
+ className?: string;
+ children: ReactNode;
+}
+
+export const AmountBadge: FC = ({ tone = 'green', className, children }) => (
+
+ {children}
+
+);
diff --git a/components/Activity/BountyAmount.tsx b/components/Activity/BountyAmount.tsx
new file mode 100644
index 000000000..0945aab73
--- /dev/null
+++ b/components/Activity/BountyAmount.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { FC } from 'react';
+import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
+import { useExchangeRate } from '@/contexts/ExchangeRateContext';
+import { getBountyDisplayAmount } from '@/components/Bounty/lib/bountyUtil';
+import { formatCurrency } from '@/utils/currency';
+import { AmountBadge } from './AmountBadge';
+import type { Bounty } from '@/types/bounty';
+
+interface BountyAmountProps {
+ bounty: Bounty;
+ className?: string;
+}
+
+export const BountyAmount: FC = ({ bounty, className }) => {
+ const { showUSD } = useCurrencyPreference();
+ const { exchangeRate } = useExchangeRate();
+ const { amount } = getBountyDisplayAmount(bounty, exchangeRate, showUSD);
+
+ return (
+
+ {formatCurrency({
+ amount: Math.round(amount),
+ showUSD,
+ exchangeRate,
+ skipConversion: true,
+ shorten: true,
+ })}
+
+ );
+};
diff --git a/components/Activity/ContributionAmount.tsx b/components/Activity/ContributionAmount.tsx
index 9eb3493fe..1ea82a025 100644
--- a/components/Activity/ContributionAmount.tsx
+++ b/components/Activity/ContributionAmount.tsx
@@ -4,15 +4,21 @@ import { FC } from 'react';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { useExchangeRate } from '@/contexts/ExchangeRateContext';
import { formatCurrency } from '@/utils/currency';
-import { cn } from '@/utils/styles';
+import { AmountBadge } from './AmountBadge';
import { resolveDisplayedContribution, type FeedContribution } from './lib/feedEntryAdapters';
interface ContributionAmountProps {
contribution: FeedContribution;
className?: string;
+ /** Prefix with "+" (default). Set false for earnings like "earned $150". */
+ showSign?: boolean;
}
-export const ContributionAmount: FC = ({ contribution, className }) => {
+export const ContributionAmount: FC = ({
+ contribution,
+ className,
+ showSign = true,
+}) => {
const { showUSD } = useCurrencyPreference();
const { exchangeRate } = useExchangeRate();
const { amount, inUSD } = resolveDisplayedContribution(contribution, showUSD, exchangeRate);
@@ -25,5 +31,5 @@ export const ContributionAmount: FC = ({ contribution,
shorten: true,
});
- return +{formatted};
+ return {showSign ? `+${formatted}` : formatted};
};
diff --git a/components/Activity/FeedEntryIcon.tsx b/components/Activity/FeedEntryIcon.tsx
index 29fdb1e6d..b984e4493 100644
--- a/components/Activity/FeedEntryIcon.tsx
+++ b/components/Activity/FeedEntryIcon.tsx
@@ -1,9 +1,16 @@
+'use client';
+
import { FC } from 'react';
-import { Bell, Coins, MessageCircle, type LucideIcon } from 'lucide-react';
+import { Bell, MessageCircle, type LucideIcon } from 'lucide-react';
+import Icon from '@/components/ui/icons/Icon';
+import { ResearchCoinIcon, RSC_COLORS } from '@/components/ui/icons/ResearchCoinIcon';
+import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import type { FeedEntryIconName } from './lib/feedEntryAdapters';
-const ICONS: Record, LucideIcon> = {
- coins: Coins,
+const ICONS: Record<
+ Exclude,
+ LucideIcon
+> = {
bell: Bell,
message: MessageCircle,
};
@@ -13,7 +20,33 @@ interface FeedEntryIconProps {
}
export const FeedEntryIcon: FC = ({ name }) => {
+ const { showUSD } = useCurrencyPreference();
+
if (!name) return null;
- const Icon = ICONS[name];
- return ;
+ if (name === 'coins') {
+ if (showUSD) return null;
+ return ;
+ }
+ if (name === 'fund') {
+ return (
+
+ );
+ }
+ if (name === 'earn') {
+ return (
+
+ );
+ }
+ if (name === 'proposal') {
+ return (
+
+ );
+ }
+ const IconComponent = ICONS[name];
+ return ;
};
diff --git a/components/Activity/GrantFundingAmount.tsx b/components/Activity/GrantFundingAmount.tsx
index 5ae5b4a5e..c1067a5be 100644
--- a/components/Activity/GrantFundingAmount.tsx
+++ b/components/Activity/GrantFundingAmount.tsx
@@ -3,7 +3,7 @@
import { FC } from 'react';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { formatCurrency } from '@/utils/currency';
-import { cn } from '@/utils/styles';
+import { AmountBadge } from './AmountBadge';
import type { FeedGrantAmount } from './lib/feedEntryAdapters';
interface GrantFundingAmountProps {
@@ -21,5 +21,5 @@ export const GrantFundingAmount: FC = ({ amount, classN
shorten: true,
});
- return {formatted};
+ return {formatted};
};
diff --git a/components/Activity/ReviewScoreStars.tsx b/components/Activity/ReviewScoreStars.tsx
new file mode 100644
index 000000000..d77d38c0d
--- /dev/null
+++ b/components/Activity/ReviewScoreStars.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { FC } from 'react';
+import { Star } from 'lucide-react';
+import { cn } from '@/utils/styles';
+
+interface ReviewScoreStarsProps {
+ score: number;
+ size?: 'sm' | 'md';
+ className?: string;
+}
+
+const SIZES = {
+ sm: 13,
+ md: 14,
+} as const;
+
+/** Five-star score display; filled up to the rounded score. */
+export const ReviewScoreStars: FC = ({ score, size = 'sm', className }) => {
+ const rounded = Math.round(score);
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ );
+};
diff --git a/components/Activity/WorkCardActions.tsx b/components/Activity/WorkCardActions.tsx
new file mode 100644
index 000000000..e26767465
--- /dev/null
+++ b/components/Activity/WorkCardActions.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { FC, ReactNode } from 'react';
+import { FeedItemActions } from '@/components/Feed/FeedItemActions';
+import type { UserVoteType } from '@/types/reaction';
+import type { FeedContentType } from '@/types/feed';
+import type { ActivityWorkContext } from './lib/activityWorkContext';
+
+interface WorkCardActionsProps {
+ work: ActivityWorkContext;
+ voteCount: number;
+ userVote?: UserVoteType;
+ cta?: ReactNode;
+}
+
+function toFeedContentType(documentType: ActivityWorkContext['documentType']): FeedContentType {
+ return documentType === 'paper' ? 'PAPER' : 'POST';
+}
+
+export const WorkCardActions: FC = ({ work, voteCount, userVote, cta }) => (
+
+);
diff --git a/components/Activity/WorkPreviewCard.tsx b/components/Activity/WorkPreviewCard.tsx
new file mode 100644
index 000000000..9b8587f01
--- /dev/null
+++ b/components/Activity/WorkPreviewCard.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import { FC, ReactNode } from 'react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { Star } from 'lucide-react';
+import { cn } from '@/utils/styles';
+import type { WorkCardAuthor, WorkCardStat } from './lib/activityWorkContext';
+
+interface WorkPreviewCardProps {
+ title: string;
+ href?: string;
+ imageSrc?: string;
+ /** Render a gradient placeholder when no image is available. */
+ showPlaceholder?: boolean;
+ authors?: WorkCardAuthor[];
+ /** Funding organization; takes precedence over authors on the meta line. */
+ organization?: string | null;
+ institution?: string | null;
+ /** Average peer-review score shown next to the authors. */
+ score?: number | null;
+ /** Extra stats on the right of the frosted bar (label + value). */
+ stats?: WorkCardStat[];
+ /** Fundraise progress in the 0–1 range. */
+ progress?: number;
+ /** Full footer row (typically vote/save/share + CTA). */
+ actions?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Full-bleed frosted-image card for activity feed rows.
+ * Image fills the card; metadata sits in a translucent bar at the bottom.
+ */
+export const WorkPreviewCard: FC = ({
+ title,
+ href,
+ imageSrc,
+ showPlaceholder = true,
+ authors = [],
+ organization,
+ institution,
+ score,
+ stats,
+ progress,
+ actions,
+ className,
+}) => {
+ const showFooter = !!actions;
+
+ const authorLine =
+ organization ||
+ (authors.length > 0
+ ? authors
+ .slice(0, 2)
+ .map((a) => a.name)
+ .join(', ') + (authors.length > 2 ? ` +${authors.length - 2}` : '')
+ : institution || null);
+
+ const imageBlock = (
+
+ {imageSrc ? (
+
+ ) : showPlaceholder ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {title}
+
+ {authorLine && (
+
{authorLine}
+ )}
+
+
+ {(score != null || stats?.length) && (
+
+ {score != null && (
+
+
+ Rating
+
+
+
+ {score.toFixed(1)}
+
+
+ )}
+ {stats?.map((s) => (
+
+
+ {s.label}
+
+
+ {s.value}
+
+
+ ))}
+
+ )}
+
+
+ {progress != null && (
+
+ )}
+
+
+ );
+
+ return (
+
+ {href ? (
+
+ {imageBlock}
+
+ ) : (
+ imageBlock
+ )}
+
+ {showFooter && (
+
+ )}
+
+ );
+};
diff --git a/components/Activity/lib/activityWorkContext.ts b/components/Activity/lib/activityWorkContext.ts
new file mode 100644
index 000000000..3b127e939
--- /dev/null
+++ b/components/Activity/lib/activityWorkContext.ts
@@ -0,0 +1,287 @@
+import { buildWorkUrl } from '@/utils/url';
+import { isFundraiseActive } from '@/components/Fund/lib/fundraiseUtils';
+import { getBountyDisplayAmount, isOpenBounty } from '@/components/Bounty/lib/bountyUtil';
+import { formatCurrency } from '@/utils/currency';
+import { isDeadlineInFuture } from '@/utils/date';
+import type { ContentType } from '@/types/work';
+import type {
+ ActivityContext,
+ FeedBountyContent,
+ FeedCommentContent,
+ FeedEntry,
+ FeedGrantContent,
+} from '@/types/feed';
+import type { Bounty } from '@/types/bounty';
+import type { Fundraise } from '@/types/funding';
+import type { AuthorProfile } from '@/types/authorProfile';
+import type { WorkGrantSummary } from '@/types/work';
+
+type ActivityBodySlot = 'fundraise' | 'bounty' | 'grant' | 'default';
+
+export interface ActivityWorkContext {
+ id: number;
+ slug: string;
+ title: string;
+ href: string;
+ imageUrl?: string;
+ documentType: ContentType;
+ unifiedDocumentId?: number | null;
+ fundraise?: Fundraise;
+ grant?: WorkGrantSummary;
+ bounty?: Bounty;
+ authors?: AuthorProfile[];
+ tab?: 'reviews' | 'bounties' | 'conversation';
+}
+
+export interface WorkCardAuthor {
+ name: string;
+ verified?: boolean;
+ authorUrl?: string;
+}
+
+export interface WorkCardStat {
+ label: string;
+ value: string;
+ accent?: boolean;
+}
+
+export type WorkCardCta =
+ | { kind: 'fund-modal'; label: 'Fund' }
+ | { kind: 'link'; label: string; href: string };
+
+export interface WorkCardPresentation {
+ authors: WorkCardAuthor[];
+ /** Funding organization, shown in place of authors when present. */
+ organization?: string | null;
+ institution?: string | null;
+ score?: number | null;
+ stats?: WorkCardStat[];
+ progress?: number;
+ cta?: WorkCardCta;
+ showComment: boolean;
+}
+
+export function getActivityBounty(entry: FeedEntry): Bounty | undefined {
+ if (entry.contentType === 'COMMENT') {
+ return (entry.content as FeedCommentContent).bounties?.[0];
+ }
+ if (entry.contentType === 'BOUNTY') {
+ return (entry.content as FeedBountyContent).bounty;
+ }
+ return undefined;
+}
+
+function resolveTabFromContext(activityContext?: ActivityContext): ActivityWorkContext['tab'] {
+ switch (activityContext) {
+ case 'tip_review':
+ case 'peer_review_published':
+ return 'reviews';
+ case 'bounty_opened':
+ case 'bounty_contributed':
+ case 'bounty_payout':
+ return 'bounties';
+ case 'comment_published':
+ return 'conversation';
+ default:
+ return undefined;
+ }
+}
+
+function resolveActivityBodySlot(
+ activityContext?: ActivityContext,
+ work?: Pick,
+ options?: { isReview?: boolean }
+): ActivityBodySlot {
+ if (activityContext === 'bounty_opened' || activityContext === 'bounty_contributed') {
+ return work?.bounty ? 'bounty' : 'default';
+ }
+ if (activityContext === 'grant_opened') {
+ return work?.grant ? 'grant' : 'default';
+ }
+ if (
+ activityContext === 'tip_review' ||
+ activityContext === 'bounty_payout' ||
+ activityContext === 'fundraise_contribution' ||
+ activityContext === 'proposal_submitted'
+ ) {
+ return work?.fundraise ? 'fundraise' : 'default';
+ }
+ if (activityContext === 'peer_review_published' || options?.isReview) {
+ return work?.fundraise ? 'fundraise' : 'default';
+ }
+ if (activityContext === 'comment_published' && work?.fundraise) {
+ return 'fundraise';
+ }
+ return 'default';
+}
+
+function toCardAuthors(authors?: AuthorProfile[]): WorkCardAuthor[] {
+ if (!authors?.length) return [];
+ return authors
+ .filter((author) => !!author.fullName?.trim())
+ .map((author) => ({
+ name: author.fullName,
+ verified: author.user?.isVerified ?? author.isVerified,
+ authorUrl: author.id === 0 ? undefined : author.profileUrl,
+ }));
+}
+
+/** Funding organization, from related work when present and the entry itself otherwise. */
+function resolveOrganization(entry: FeedEntry, work: ActivityWorkContext): string | null {
+ if (work.grant?.organization) return work.grant.organization;
+ if (entry.contentType === 'GRANT') {
+ return (entry.content as FeedGrantContent).grant?.organization || null;
+ }
+ return null;
+}
+
+function formatAmount(
+ amount: number,
+ showUSD: boolean,
+ exchangeRate: number,
+ skipConversion = false
+): string {
+ return formatCurrency({
+ amount: Math.round(amount),
+ showUSD,
+ exchangeRate,
+ skipConversion,
+ shorten: true,
+ });
+}
+
+export function getWorkCardPresentation(
+ entry: FeedEntry,
+ work: ActivityWorkContext,
+ options: { showUSD: boolean; exchangeRate: number; isReview?: boolean }
+): WorkCardPresentation {
+ const { showUSD, exchangeRate, isReview } = options;
+ const slot = resolveActivityBodySlot(entry.activityContext, work, { isReview });
+
+ // Prefer real document score; omit when absent (no mocks).
+ const score =
+ entry.metrics?.reviewScore && entry.metrics.reviewScore > 0
+ ? entry.metrics.reviewScore
+ : work.fundraise?.reviewMetrics?.avg && work.fundraise.reviewMetrics.avg > 0
+ ? work.fundraise.reviewMetrics.avg
+ : null;
+
+ const authors = toCardAuthors(work.authors);
+ const institution = entry.nonprofit?.name ?? null;
+ const base: WorkCardPresentation = {
+ authors,
+ organization: resolveOrganization(entry, work),
+ institution,
+ score,
+ // Caller ANDs with commentPreview presence; here we only gate by slot.
+ showComment: slot !== 'bounty' && slot !== 'grant',
+ };
+
+ if (slot === 'fundraise' && work.fundraise) {
+ const fundraise = work.fundraise;
+ const goalAmount = showUSD ? fundraise.goalAmount.usd : fundraise.goalAmount.rsc;
+ const goalUsd = fundraise.goalAmount.usd;
+ const raisedUsd = fundraise.amountRaised.usd;
+
+ return {
+ ...base,
+ stats: [
+ {
+ label: 'Raising',
+ value: formatAmount(goalAmount, showUSD, exchangeRate, true),
+ accent: true,
+ },
+ ],
+ progress: goalUsd > 0 ? raisedUsd / goalUsd : undefined,
+ cta: isFundraiseActive(fundraise) ? { kind: 'fund-modal', label: 'Fund' } : undefined,
+ };
+ }
+
+ if (slot === 'grant' && work.grant) {
+ const grant = work.grant;
+ const isActive =
+ grant.status === 'OPEN' && (grant.endDate ? isDeadlineInFuture(grant.endDate) : true);
+ const budgetAmount = showUSD ? grant.amount.usd : (grant.amount.rsc ?? 0);
+ const hasBudget = grant.amount.usd > 0 || (grant.amount.rsc ?? 0) > 0;
+ const stats: WorkCardStat[] = [];
+
+ if (hasBudget) {
+ stats.push({
+ label: 'Available',
+ value: formatAmount(budgetAmount, showUSD, exchangeRate, showUSD),
+ accent: true,
+ });
+ }
+ stats.push({
+ label: 'Proposals',
+ value: String(grant.numApplicants),
+ });
+
+ return {
+ ...base,
+ stats: stats.length ? stats : undefined,
+ cta: isActive ? { kind: 'link', label: 'Apply', href: work.href } : undefined,
+ };
+ }
+
+ if (slot === 'bounty' && work.bounty) {
+ const bounty = work.bounty;
+ const { amount } = getBountyDisplayAmount(bounty, exchangeRate, showUSD);
+ const isReviewBounty = bounty.bountyType === 'REVIEW';
+ const href = `${buildWorkUrl({
+ id: work.id,
+ slug: work.slug,
+ contentType: work.documentType,
+ tab: 'bounties',
+ })}?focus=true`;
+ const active =
+ bounty.status === 'OPEN'
+ ? bounty.expirationDate
+ ? isDeadlineInFuture(bounty.expirationDate)
+ : true
+ : bounty.status === 'ASSESSMENT' || isOpenBounty(bounty);
+
+ return {
+ ...base,
+ stats: [
+ {
+ label: isReviewBounty ? 'Peer Review' : 'Bounty',
+ value: formatAmount(amount, showUSD, exchangeRate, true),
+ accent: true,
+ },
+ ],
+ cta: active ? { kind: 'link', label: isReviewBounty ? 'Review' : 'Solve', href } : undefined,
+ };
+ }
+
+ return base;
+}
+
+export function getActivityWorkContext(entry: FeedEntry): ActivityWorkContext | null {
+ const related = entry.relatedWork;
+ if (!related?.title) return null;
+
+ const tab = resolveTabFromContext(entry.activityContext);
+ const documentType = related.contentType;
+ const href = buildWorkUrl({
+ id: related.id,
+ slug: related.slug,
+ contentType: documentType,
+ tab,
+ });
+
+ return {
+ id: related.id,
+ slug: related.slug,
+ title: related.title,
+ href,
+ imageUrl: related.image,
+ documentType,
+ unifiedDocumentId: related.unifiedDocumentId,
+ fundraise: related.fundraise,
+ grant: related.grantSummary,
+ bounty: getActivityBounty(entry),
+ authors: related.authors?.map((authorship) => authorship.authorProfile),
+ tab,
+ };
+}
diff --git a/components/Activity/lib/deriveActivityContext.ts b/components/Activity/lib/deriveActivityContext.ts
new file mode 100644
index 000000000..880fd4052
--- /dev/null
+++ b/components/Activity/lib/deriveActivityContext.ts
@@ -0,0 +1,40 @@
+import type { ActivityContext, RawApiFeedEntry } from '@/types/feed';
+
+const REVIEW_COMMENT_TYPES = new Set(['PEER_REVIEW', 'REVIEW']);
+
+export function deriveActivityContext(feedEntry: RawApiFeedEntry): ActivityContext | undefined {
+ const contentType = feedEntry.content_type?.toUpperCase();
+ const obj = feedEntry.content_object;
+ if (!contentType || !obj) return undefined;
+
+ switch (contentType) {
+ case 'RHCOMMENTMODEL': {
+ const commentType = obj.comment_type as string | undefined;
+ if (commentType && REVIEW_COMMENT_TYPES.has(commentType)) {
+ return 'peer_review_published';
+ }
+ if (Array.isArray(obj.bounties) && obj.bounties.length > 0) {
+ return 'bounty_opened';
+ }
+ return 'comment_published';
+ }
+ case 'RESEARCHHUBPOST': {
+ if (obj.type === 'GRANT') return 'grant_opened';
+ if (obj.type === 'PREREGISTRATION') return 'proposal_submitted';
+ return undefined;
+ }
+ case 'PURCHASE':
+ case 'USDFUNDRAISECONTRIBUTION':
+ return 'fundraise_contribution';
+ case 'FUNDINGACTIVITY': {
+ const sourceType = obj.source_type as string | undefined;
+ if (sourceType === 'BOUNTY_PAYOUT') return 'bounty_payout';
+ if (sourceType === 'TIP_REVIEW') return 'tip_review';
+ return undefined;
+ }
+ case 'BOUNTY':
+ return 'bounty_contributed';
+ default:
+ return undefined;
+ }
+}
diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts
index f8e4e558a..48d5146b1 100644
--- a/components/Activity/lib/feedEntryAdapters.ts
+++ b/components/Activity/lib/feedEntryAdapters.ts
@@ -1,8 +1,10 @@
import { buildWorkUrl } from '@/utils/url';
+import { isFoundationUser } from '@/components/Bounty/lib/bountyUtil';
import type {
FeedCommentContent,
FeedContentType,
FeedEntry,
+ FeedFundingActivityContent,
FeedGrantContent,
FeedPaperContent,
FeedPostContent,
@@ -15,13 +17,13 @@ import type { ContentType } from '@/types/work';
const COMMENT_ACTION_LABELS: Record = {
GENERIC_COMMENT: 'commented on',
REVIEW: 'peer reviewed',
- AUTHOR_UPDATE: 'posted an update on',
+ AUTHOR_UPDATE: 'posted an update',
ANSWER: 'answered on',
BOUNTY: 'contributed to',
};
const DOC_ACTION_LABELS: Partial> = {
- GRANT: 'opened funding',
+ GRANT: 'opened an RFP for',
PREREGISTRATION: 'submitted proposal',
POST: 'posted discussion',
PAPER: 'published preprint',
@@ -34,17 +36,111 @@ const FEED_TO_CONTENT_TYPE: Partial> = {
PAPER: 'paper',
};
-export function getActionLabel(entry: FeedEntry): string {
+export interface ActivityHeaderTarget {
+ author: AuthorProfile;
+ suffix?: string;
+}
+
+export interface ActivityHeaderMessage {
+ actor: AuthorProfile;
+ verb: string;
+ target?: ActivityHeaderTarget;
+ /** Payout rather than contribution — the amount renders without a "+". */
+ isEarning?: boolean;
+}
+
+/**
+ * True when the profile belongs to the ResearchHub Foundation account.
+ *
+ * Feed payloads are inconsistent about which id they expose for an actor, so we
+ * prefer the explicit user fields and only fall back to `id` when the profile
+ * carries no user reference at all (funder objects are serialized that way).
+ */
+function isFoundationProfile(profile?: AuthorProfile): boolean {
+ if (!profile) return false;
+ if (profile.userId != null) return isFoundationUser(profile.userId);
+ if (profile.user?.id != null) return isFoundationUser(profile.user.id);
+ return isFoundationUser(profile.id);
+}
+
+function getFundingActivityMessage(content: FeedFundingActivityContent): ActivityHeaderMessage {
+ const actor = content.createdBy;
+ const recipient = content.recipient;
+
+ // Foundation tips and bounty awards read as the recipient earning, not the
+ // Foundation paying — the reviewer is the interesting subject.
+ if (isFoundationProfile(actor) && recipient) {
+ return { actor: recipient, verb: 'earned', isEarning: true };
+ }
+
+ if (content.sourceType === 'BOUNTY_PAYOUT') {
+ return {
+ actor,
+ verb: recipient ? 'awarded bounty to' : 'awarded bounty',
+ target: recipient ? { author: recipient } : undefined,
+ isEarning: true,
+ };
+ }
+
+ if (!recipient) {
+ return { actor, verb: 'tipped review' };
+ }
+ return {
+ actor,
+ verb: 'tipped',
+ target: {
+ author: recipient,
+ suffix: ' on peer review',
+ },
+ };
+}
+
+function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage {
+ const actor = entry.content.createdBy;
+
if (entry.contentType === 'COMMENT') {
const commentContent = entry.content as FeedCommentContent;
- if (commentContent.hasBounties) return 'opened a bounty';
- return COMMENT_ACTION_LABELS[commentContent.comment?.commentType] ?? 'commented on';
+ const bounty = commentContent.bounties?.[0];
+ if (bounty) {
+ const isReviewBounty = bounty.bountyType === 'REVIEW';
+ return {
+ actor,
+ verb: isReviewBounty ? 'opened a peer review bounty for' : 'opened a bounty for',
+ };
+ }
+
+ const commentType = commentContent.comment?.commentType;
+ if (commentType === 'REVIEW') {
+ if (getReviewEarning(entry)) return { actor, verb: 'earned', isEarning: true };
+ const score = commentContent.review?.score ?? commentContent.comment.reviewScore;
+ return { actor, verb: score ? 'peer reviewed and scored' : 'peer reviewed' };
+ }
+
+ return {
+ actor,
+ verb: COMMENT_ACTION_LABELS[commentType] ?? 'commented on',
+ };
+ }
+
+ if (entry.contentType === 'BOUNTY') {
+ return { actor, verb: 'contributed to' };
}
- if (entry.contentType === 'BOUNTY') return 'contributed to';
+
if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') {
- return 'Funded Proposal';
+ return { actor, verb: 'funded proposal' };
}
- return DOC_ACTION_LABELS[entry.contentType] ?? 'contributed';
+
+ return {
+ actor,
+ verb: DOC_ACTION_LABELS[entry.contentType] ?? 'contributed to',
+ };
+}
+
+export function getActivityHeaderMessage(entry: FeedEntry): ActivityHeaderMessage {
+ if (entry.contentType === 'FUNDINGACTIVITY') {
+ return getFundingActivityMessage(entry.content as FeedFundingActivityContent);
+ }
+ return getDefaultActivityMessage(entry);
}
export interface FeedEntryMeta {
@@ -60,12 +156,45 @@ function resolveCommentWorkTab(
comment: FeedCommentContent | null
): CommentWorkTab {
if (comment?.comment?.commentType === 'REVIEW') return 'reviews';
- if (entry.contentType === 'BOUNTY' || comment?.hasBounties) return 'bounties';
+ if (entry.contentType === 'BOUNTY' || (comment?.bounties?.length ?? 0) > 0) return 'bounties';
if (entry.contentType === 'COMMENT') return 'conversation';
return undefined;
}
+function resolveRelatedWorkTab(entry: FeedEntry): CommentWorkTab {
+ if (entry.contentType === 'FUNDINGACTIVITY') {
+ const funding = entry.content as FeedFundingActivityContent;
+ return funding.sourceType === 'BOUNTY_PAYOUT' ? 'bounties' : 'reviews';
+ }
+ if (entry.contentType === 'COMMENT') {
+ return resolveCommentWorkTab(entry, entry.content as FeedCommentContent);
+ }
+ if (entry.contentType === 'BOUNTY') return 'bounties';
+ return undefined;
+}
+
+function getRelatedWorkMeta(entry: FeedEntry): FeedEntryMeta | null {
+ const related = entry.relatedWork;
+ if (!related?.title) return null;
+
+ const tab = resolveRelatedWorkTab(entry);
+
+ return {
+ title: related.title,
+ author: entry.content.createdBy,
+ href: buildWorkUrl({
+ id: related.id,
+ slug: related.slug,
+ contentType: related.contentType,
+ tab,
+ }),
+ };
+}
+
export function getEntryMeta(entry: FeedEntry): FeedEntryMeta {
+ const relatedMeta = getRelatedWorkMeta(entry);
+ if (relatedMeta) return relatedMeta;
+
const content = entry.content;
const author = content.createdBy;
@@ -117,17 +246,30 @@ export function getEntryMeta(entry: FeedEntry): FeedEntryMeta {
};
}
-export type FeedEntryIconName = 'coins' | 'bell' | 'message' | null;
+export type FeedEntryIconName = 'coins' | 'fund' | 'earn' | 'proposal' | 'bell' | 'message' | null;
export function getActionIcon(entry: FeedEntry): FeedEntryIconName {
- if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') {
+ if (entry.contentType === 'GRANT' || entry.activityContext === 'grant_opened') {
+ return 'fund';
+ }
+ if (entry.activityContext === 'bounty_opened') {
+ return 'earn';
+ }
+ if (entry.activityContext === 'proposal_submitted' || entry.contentType === 'PREREGISTRATION') {
+ return null;
+ }
+ if (
+ entry.contentType === 'USDFUNDRAISECONTRIBUTION' ||
+ entry.contentType === 'PURCHASE' ||
+ entry.contentType === 'FUNDINGACTIVITY'
+ ) {
return 'coins';
}
if (entry.contentType === 'BOUNTY') return 'coins';
if (entry.contentType !== 'COMMENT') return null;
const commentContent = entry.content as FeedCommentContent;
- if (commentContent.hasBounties) return 'coins';
+ if ((commentContent.bounties?.length ?? 0) > 0) return 'coins';
const commentType = commentContent.comment?.commentType;
if (commentType === 'AUTHOR_UPDATE') return 'bell';
@@ -136,18 +278,40 @@ export function getActionIcon(entry: FeedEntry): FeedEntryIconName {
}
export function getReviewScore(entry: FeedEntry): number | undefined {
+ if (entry.contentType === 'FUNDINGACTIVITY') return undefined;
if (entry.contentType !== 'COMMENT') return undefined;
const commentContent = entry.content as FeedCommentContent;
if (commentContent.comment?.commentType !== 'REVIEW') return undefined;
+ // When the header leads with an earning, the score stays on the document card.
+ if (getReviewEarning(entry)) return undefined;
return commentContent.review?.score ?? commentContent.comment.reviewScore;
}
+/** Bounty payout shown on the header line for awarded peer reviews. */
+export function getReviewEarning(entry: FeedEntry): FeedContribution | undefined {
+ if (entry.contentType !== 'COMMENT') return undefined;
+ const commentContent = entry.content as FeedCommentContent;
+ if (commentContent.comment?.commentType !== 'REVIEW') return undefined;
+
+ const awarded = entry.awardedBountyAmount;
+ if (awarded == null || awarded <= 0) return undefined;
+
+ return { amount: awarded, currency: 'RSC' };
+}
+
export interface FeedContribution {
amount: number;
currency: 'USD' | 'RSC';
}
export function getContribution(entry: FeedEntry): FeedContribution | undefined {
+ if (entry.contentType === 'FUNDINGACTIVITY') {
+ const funding = entry.content as FeedFundingActivityContent;
+ if (funding.totalUsdCents > 0) {
+ return { amount: funding.totalUsd, currency: 'USD' };
+ }
+ return { amount: funding.totalAmount, currency: 'RSC' };
+ }
if (entry.contentType !== 'USDFUNDRAISECONTRIBUTION' && entry.contentType !== 'PURCHASE') {
return undefined;
}
@@ -199,6 +363,7 @@ export interface FeedCommentPreview {
}
export function getCommentPreview(entry: FeedEntry): FeedCommentPreview | null {
+ if (entry.contentType === 'FUNDINGACTIVITY') return null;
if (entry.contentType !== 'COMMENT') return null;
const { comment } = entry.content as FeedCommentContent;
if (!comment?.content) return null;
diff --git a/components/Comment/CommentReadOnly.tsx b/components/Comment/CommentReadOnly.tsx
index 9dcab35ff..b60b8d073 100644
--- a/components/Comment/CommentReadOnly.tsx
+++ b/components/Comment/CommentReadOnly.tsx
@@ -25,6 +25,7 @@ interface CommentReadOnlyProps {
maxLength?: number;
initiallyExpanded?: boolean;
showReadMoreButton?: boolean;
+ showLinkPreviews?: boolean;
createdDate?: string | Date;
updatedDate?: string | Date;
className?: string;
@@ -68,6 +69,7 @@ export const CommentReadOnly = ({
maxLength = 1000,
initiallyExpanded = false,
showReadMoreButton = true,
+ showLinkPreviews = true,
createdDate,
updatedDate,
className,
@@ -79,7 +81,8 @@ export const CommentReadOnly = ({
// Embeds derived from the comment doc — surfaced as a carousel below the
// body so a single saved comment can show multiple link previews without
// breaking the prose flow. Only meaningful for TipTap content.
- const carouselEmbeds = contentFormat === 'TIPTAP' ? extractDocEmbeds(parsedContent) : [];
+ const carouselEmbeds =
+ showLinkPreviews && contentFormat === 'TIPTAP' ? extractDocEmbeds(parsedContent) : [];
const textContent =
contentFormat === 'TIPTAP'
@@ -147,6 +150,7 @@ export const CommentReadOnly = ({
truncate={shouldTruncate && !isExpanded}
maxLength={maxLength}
debug={debugEnabled}
+ showLinkPreviews={showLinkPreviews}
/>,
];
} catch (error) {
diff --git a/components/Comment/lib/TipTapRenderer.tsx b/components/Comment/lib/TipTapRenderer.tsx
index 4debe84d2..cd6164db8 100644
--- a/components/Comment/lib/TipTapRenderer.tsx
+++ b/components/Comment/lib/TipTapRenderer.tsx
@@ -15,6 +15,7 @@ interface TipTapRendererProps {
renderSectionHeader?: (props: SectionHeaderProps) => ReactNode;
truncate?: boolean;
maxLength?: number;
+ showLinkPreviews?: boolean;
}
/**
@@ -67,6 +68,25 @@ export const renderTextWithMarks = (text: string, marks: any[]): ReactNode => {
return result;
};
+function demoteRichLinks(node: any): any {
+ if (!node || typeof node !== 'object') return node;
+
+ if (node.type === 'richLink' && node.attrs?.url) {
+ const url = String(node.attrs.url);
+ return {
+ type: 'text',
+ text: url,
+ marks: [{ type: 'link', attrs: { href: url, target: '_blank' } }],
+ };
+ }
+
+ if (Array.isArray(node.content)) {
+ return { ...node, content: node.content.map(demoteRichLinks) };
+ }
+
+ return node;
+}
+
/**
* Helper function to extract plain text from TipTap JSON
*/
@@ -107,6 +127,7 @@ const TipTapRenderer: React.FC = ({
renderSectionHeader,
truncate = false,
maxLength = 300,
+ showLinkPreviews = true,
}) => {
if (debug) {
console.log('||TipTapRenderer props received:', {
@@ -146,7 +167,11 @@ const TipTapRenderer: React.FC = ({
// whose anchor text equals the href become `richLink` nodes so they
// render with the same inline preview + hover surface as freshly pasted
// links. Idempotent — already-converted docs pass through unchanged.
- documentContent = normalizeRichLinks(documentContent);
+ // Skip when link previews are disabled (e.g. activity feed) and demote
+ // any existing richLink atoms back to plain anchors.
+ documentContent = showLinkPreviews
+ ? normalizeRichLinks(documentContent)
+ : demoteRichLinks(documentContent);
// If truncation is enabled, extract the full text to check length
let shouldTruncate = false;
diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx
index 5107b1c3c..f5d251fa9 100644
--- a/components/Feed/FeedItemActions.tsx
+++ b/components/Feed/FeedItemActions.tsx
@@ -169,6 +169,11 @@ interface FeedItemActionsProps {
isExpanded?: boolean;
className?: string;
variant?: 'default' | 'inline';
+ /**
+ * When true, render share + save next to the vote control (left) so the
+ * trailing side can hold only `rightSideActionButton` (e.g. Fund / Review).
+ */
+ leadingUtilityActions?: boolean;
}
// Define interface for avatar items used in local state
@@ -207,6 +212,7 @@ export const FeedItemActions: FC = ({
isExpanded = false,
className,
variant = 'default',
+ leadingUtilityActions = false,
}) => {
const { executeAuthenticatedAction } = useAuthenticatedAction();
const { showUSD } = useCurrencyPreference();
@@ -407,6 +413,58 @@ export const FeedItemActions: FC = ({
const tipAmount = tips.reduce((total, tip) => total + (tip.amount || 0), 0);
const totalAwarded = tipAmount + (awardedBountyAmount || 0);
+ const canSave =
+ !!relatedDocumentUnifiedDocumentId &&
+ feedContentType !== 'COMMENT' &&
+ feedContentType !== 'BOUNTY' &&
+ feedContentType !== 'APPLICATION' &&
+ showPeerReviews;
+
+ const showShare = leadingUtilityActions || variant !== 'inline';
+ const showMoreMenu =
+ !!(listDetailContext && relatedDocumentUnifiedDocumentId) ||
+ menuItems.length > 0 ||
+ !hideReportButton;
+
+ const shareButton = showShare ? (
+
+ ) : null;
+
+ const saveButton = canSave ? (
+
+ ) : null;
+
return (
<>
= ({
/>
)}
{children}
+ {leadingUtilityActions && (
+ <>
+ {saveButton}
+ {shareButton}
+ >
+ )}
{rightSideActionButton}
-
{
- e.stopPropagation();
- }}
- onClick={(e) => {
- e.stopPropagation();
- }}
- variant="ghost"
- size="sm"
- className="flex h-8 w-8 !p-0 items-center justify-center rounded-full text-gray-700 transition-all hover:bg-white hover:text-gray-900 hover:shadow-sm"
- >
-
-
- }
- align="end"
- open={isMenuOpen}
- onOpenChange={setIsMenuOpen}
- >
- {listDetailContext && relatedDocumentUnifiedDocumentId && (
-
-
- Remove from list
-
- )}
-
- {menuItems.map((item, index) => (
- {
- setIsMenuOpen(false);
- item.onClick(e);
- }}
- className={cn('flex items-center gap-2', item.className)}
- >
- {item.icon && }
- {item.label}
-
- ))}
-
- {showSeparator && }
-
- {!hideReportButton && (
-
-
- {actionLabels?.report || 'Report'}
-
- )}
-
- {variant !== 'inline' && (
-
+ }
+ align="end"
+ open={isMenuOpen}
+ onOpenChange={setIsMenuOpen}
>
-
-
+ {listDetailContext && relatedDocumentUnifiedDocumentId && (
+
+
+ Remove from list
+
+ )}
+
+ {menuItems.map((item, index) => (
+
{
+ setIsMenuOpen(false);
+ item.onClick(e);
+ }}
+ className={cn('flex items-center gap-2', item.className)}
+ >
+ {item.icon && }
+ {item.label}
+
+ ))}
+
+ {showSeparator &&
}
+
+ {!hideReportButton && (
+
+
+ {actionLabels?.report || 'Report'}
+
+ )}
+
+ )}
+ {!leadingUtilityActions && (
+ <>
+ {shareButton}
+ {saveButton}
+ >
)}
- {relatedDocumentUnifiedDocumentId &&
- feedContentType !== 'COMMENT' &&
- feedContentType !== 'BOUNTY' &&
- feedContentType !== 'APPLICATION' &&
- showPeerReviews && (
-
- )}
diff --git a/components/Funding/ActivityCard.tsx b/components/Funding/ActivityCard.tsx
index 6fe9303ef..80db42d48 100644
--- a/components/Funding/ActivityCard.tsx
+++ b/components/Funding/ActivityCard.tsx
@@ -2,37 +2,49 @@
import { FC } from 'react';
import Link from 'next/link';
-import { Star } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
-import { formatTimeAgo } from '@/utils/date';
-import type { FeedEntry } from '@/types/feed';
+import { ActivityHeaderActionText } from '@/components/Activity/ActivityHeaderActionText';
+import { BountyAmount } from '@/components/Activity/BountyAmount';
+import { ContributionAmount } from '@/components/Activity/ContributionAmount';
+import { FeedEntryIcon } from '@/components/Activity/FeedEntryIcon';
+import { GrantFundingAmount } from '@/components/Activity/GrantFundingAmount';
+import { ReviewScoreStars } from '@/components/Activity/ReviewScoreStars';
import {
getActionIcon,
- getActionLabel,
+ getActivityHeaderMessage,
getContribution,
getEntryMeta,
getGrantAmount,
+ getReviewEarning,
getReviewScore,
} from '@/components/Activity/lib/feedEntryAdapters';
-import { ContributionAmount } from '@/components/Activity/ContributionAmount';
-import { FeedEntryIcon } from '@/components/Activity/FeedEntryIcon';
-import { GrantFundingAmount } from '@/components/Activity/GrantFundingAmount';
+import { getActivityBounty } from '@/components/Activity/lib/activityWorkContext';
+import { formatTimeAgo } from '@/utils/date';
+import { Tooltip } from '@/components/ui/Tooltip';
+import type { FeedEntry } from '@/types/feed';
interface ActivityCardProps {
entry: FeedEntry;
}
+/** Compact activity row used in the funding sidebar. */
export const ActivityCard: FC
= ({ entry }) => {
- const { title, author, href } = getEntryMeta(entry);
+ const { title, href } = getEntryMeta(entry);
if (!title) return null;
- const actionLabel = getActionLabel(entry);
+ const message = getActivityHeaderMessage(entry);
const actionIcon = getActionIcon(entry);
const reviewScore = getReviewScore(entry);
+ const reviewEarning = getReviewEarning(entry);
const grantAmount = getGrantAmount(entry);
const contribution = getContribution(entry);
+ const bounty = entry.activityContext === 'bounty_opened' ? getActivityBounty(entry) : undefined;
+
+ const hasAmount = Boolean(
+ grantAmount || contribution || reviewEarning || bounty || reviewScore != null
+ );
const titleEl = href ? (
@@ -45,51 +57,66 @@ export const ActivityCard: FC = ({ entry }) => {
return (
-
-
+
- {author?.id ? (
-
-
- {author.fullName || 'Unknown'}
-
-
- ) : (
-
- {author?.fullName || 'Unknown'}
-
- )}
-
- {actionLabel}
-
- {reviewScore != null && (
-
-
- {reviewScore.toFixed(1)}
-
+
+
+ {grantAmount && (
+ <>
+ {' '}
+
+ >
)}
- {grantAmount && }
{contribution && (
-
+ <>
+ {' '}
+
+ >
)}
+ {reviewEarning && (
+ <>
+ {' '}
+
+ >
+ )}
+ {bounty && (
+ <>
+ {' '}
+
+ >
+ )}
+ {reviewScore != null && reviewScore > 0 && (
+ <>
+ {' '}
+
+ >
+ )}
+
{titleEl}
-
- {formatTimeAgo(entry.timestamp)}
-
+
+
+ {formatTimeAgo(entry.timestamp)}
+
+
);
};
diff --git a/components/Funding/FundSidebar.tsx b/components/Funding/FundSidebar.tsx
new file mode 100644
index 000000000..c626e0972
--- /dev/null
+++ b/components/Funding/FundSidebar.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { FundingPowerCard } from './FundingPowerCard';
+import { RecentlyVisitedCard, useRecentlyVisited } from './RecentlyVisitedCard';
+import { cn } from '@/utils/styles';
+
+/**
+ * Shared Fund right column (Activity / RFPs / Proposals): funding power on top,
+ * recently visited beneath (dropped entirely once cleared / empty).
+ */
+export function FundSidebar() {
+ const recentlyVisited = useRecentlyVisited();
+ const showsRecentlyVisited = recentlyVisited.pages.length > 0;
+
+ return (
+
+
+ {showsRecentlyVisited && (
+
+ )}
+
+ );
+}
diff --git a/components/Funding/FundingBannerSkeleton.tsx b/components/Funding/FundingBannerSkeleton.tsx
index a411457f8..e6e0a41c9 100644
--- a/components/Funding/FundingBannerSkeleton.tsx
+++ b/components/Funding/FundingBannerSkeleton.tsx
@@ -36,6 +36,10 @@ export function FundingBannerSkeleton({ showTabs = false }: FundingBannerSkeleto
+
)}
diff --git a/components/Funding/FundingPowerCard.tsx b/components/Funding/FundingPowerCard.tsx
new file mode 100644
index 000000000..0b9bc6343
--- /dev/null
+++ b/components/Funding/FundingPowerCard.tsx
@@ -0,0 +1,227 @@
+'use client';
+
+import Link from 'next/link';
+import { Plus } from 'lucide-react';
+import { RSC_COLORS } from '@/components/ui/icons/ResearchCoinIcon';
+import { Tooltip } from '@/components/ui/Tooltip';
+import { formatCurrency } from '@/utils/currency';
+import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
+import { useExchangeRate } from '@/contexts/ExchangeRateContext';
+import { useUser } from '@/contexts/UserContext';
+import { getAvailableAndPromotionalRscBalance } from '@/components/ResearchCoin/lib/promotionalBalance';
+import { cn } from '@/utils/styles';
+
+interface FundingPowerCardProps {
+ className?: string;
+}
+
+/**
+ * Wallet card for the Activity sidebar. Leads with total funding power,
+ * visualizes the split between RSC and fund-only credits, and surfaces quick
+ * actions to deposit, earn, or fund research.
+ */
+export const FundingPowerCard = ({ className }: FundingPowerCardProps) => {
+ const { user, isLoading: isUserLoading } = useUser();
+ const { showUSD } = useCurrencyPreference();
+ const { exchangeRate, isLoading: isRateLoading } = useExchangeRate();
+
+ // Wait for auth + (when showing USD) a usable rate so we don't flash the
+ // empty CTA or an RSC figure that then swaps to dollars.
+ const isReady = !isUserLoading && (!showUSD || (!isRateLoading && exchangeRate > 0));
+
+ if (!isReady) {
+ return ;
+ }
+
+ const fmt = (rscAmount: number) =>
+ formatCurrency({
+ amount: showUSD ? rscAmount * exchangeRate : rscAmount,
+ showUSD,
+ exchangeRate,
+ shorten: true,
+ skipConversion: true,
+ });
+
+ const balanceRaw = getAvailableAndPromotionalRscBalance(user);
+ const creditsRaw = user?.fundingCredits ?? 0;
+ const total = balanceRaw + creditsRaw;
+ const isEmpty = !user || total === 0;
+
+ const rscWidth = total > 0 ? (balanceRaw / total) * 100 : 0;
+ const creditsWidth = total > 0 ? (creditsRaw / total) * 100 : 0;
+
+ return (
+
+
+ )}
+
+ {isEmpty && (
+
+ Deposit ResearchCoin or earn fund-only credits by peer reviewing — then put it toward
+ research you believe in.
+
+ )}
+
+ {isEmpty && (
+
+
Deposit RSC
+
Earn credits
+
+ )}
+
+ {!isEmpty && (
+
+
+
+
+ )}
+
+ );
+};
+
+const FundingPowerCardSkeleton = ({ className }: { className?: string }) => (
+
+);
+
+interface SourceRowProps {
+ label: string;
+ tooltip: string;
+ dotColor: string;
+ value: string;
+ valueClassName?: string;
+}
+
+const SourceRow = ({ label, tooltip, dotColor, value, valueClassName }: SourceRowProps) => (
+
+
+
+ {label}
+
+ {value}
+
+
+
+);
+
+interface CtaProps {
+ href: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+const PrimaryCta = ({ href, children, className }: CtaProps) => (
+
+ {children}
+
+);
+
+const SecondaryCta = ({ href, children, className }: CtaProps) => (
+
+ {children}
+
+);
diff --git a/components/Funding/GrantContentSwitcher.tsx b/components/Funding/GrantContentSwitcher.tsx
index 706f1dd22..e29e8100a 100644
--- a/components/Funding/GrantContentSwitcher.tsx
+++ b/components/Funding/GrantContentSwitcher.tsx
@@ -5,22 +5,15 @@ import { useInView } from 'react-intersection-observer';
import { useGrantTab } from '@/components/Funding/GrantPageContent';
import { GrantDetailsInline } from '@/components/Funding/GrantDetailsInline';
import { ActivityCardFull } from '@/components/Activity/ActivityCardFull';
+import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton';
interface GrantContentSwitcherProps {
children: ReactNode;
content?: string;
imageUrl?: string;
- hasDescription: boolean;
- grantId?: number | string;
}
-export function GrantContentSwitcher({
- children,
- content,
- imageUrl,
- hasDescription,
- grantId,
-}: GrantContentSwitcherProps) {
+export function GrantContentSwitcher({ children, content, imageUrl }: GrantContentSwitcherProps) {
const { activeTab, activity } = useGrantTab();
const { entries, isLoading, isLoadingMore, hasMore, loadMore } = activity;
@@ -46,22 +39,8 @@ export function GrantContentSwitcher({
))}
- {(isLoading || isLoadingMore) && (
-
- {[...Array(8)].map((_, i) => (
-
- ))}
-
- )}
+ {(isLoading || isLoadingMore) &&
+ [...Array(8)].map((_, i) => )}
{!isLoading && !isLoadingMore && entries.length === 0 && (
diff --git a/components/Funding/RecentlyVisitedCard.tsx b/components/Funding/RecentlyVisitedCard.tsx
new file mode 100644
index 000000000..c47b16698
--- /dev/null
+++ b/components/Funding/RecentlyVisitedCard.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import { useCallback, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { useActivityFeed } from '@/hooks/useActivityFeed';
+import { getEntryMeta } from '@/components/Activity/lib/feedEntryAdapters';
+import { cn } from '@/utils/styles';
+
+const MAX_ITEMS = 10;
+
+const ENTRY_TYPE_LABELS: Record
= {
+ GRANT: 'Request for Proposal',
+ PREREGISTRATION: 'Proposal',
+ USDFUNDRAISECONTRIBUTION: 'Proposal',
+ PURCHASE: 'Proposal',
+ PAPER: 'Paper',
+ POST: 'Post',
+};
+
+/** Comment/bounty entries point at a document, so label them by that work. */
+const WORK_TYPE_LABELS: Record = {
+ paper: 'Paper',
+ post: 'Post',
+ preregistration: 'Proposal',
+ question: 'Question',
+ discussion: 'Discussion',
+ funding_request: 'Request for Proposal',
+};
+
+interface RecentPage {
+ href: string;
+ title: string;
+ typeLabel?: string;
+}
+
+export interface RecentlyVisited {
+ pages: RecentPage[];
+ clear: () => void;
+}
+
+/**
+ * The viewer's recent pages plus the ability to forget them. Lifted out of the
+ * card so the surrounding column can drop the section entirely once it's
+ * cleared, rather than leaving an empty panel behind.
+ *
+ * Sources the activity feed until real visit tracking exists.
+ */
+export function useRecentlyVisited(): RecentlyVisited {
+ const { entries, isLoading } = useActivityFeed();
+ const [isCleared, setIsCleared] = useState(false);
+
+ const pages = useMemo(() => {
+ const collected: RecentPage[] = [];
+ const seen = new Set();
+
+ for (const entry of entries) {
+ const { title, href } = getEntryMeta(entry);
+ if (!title || !href || seen.has(href)) continue;
+ seen.add(href);
+ const relatedType = entry.relatedWork?.contentType;
+ collected.push({
+ href,
+ title,
+ typeLabel:
+ ENTRY_TYPE_LABELS[entry.contentType] ??
+ (relatedType ? WORK_TYPE_LABELS[relatedType] : undefined),
+ });
+ if (collected.length === MAX_ITEMS) break;
+ }
+
+ return collected;
+ }, [entries]);
+
+ const clear = useCallback(() => setIsCleared(true), []);
+
+ return { pages: isLoading || isCleared ? [] : pages, clear };
+}
+
+interface RecentlyVisitedCardProps extends RecentlyVisited {
+ className?: string;
+}
+
+/**
+ * Lightweight browsing history for the Activity sidebar: a plain text list of
+ * documents from the activity feed, no thumbnails or metrics.
+ */
+export function RecentlyVisitedCard({ pages, clear, className }: RecentlyVisitedCardProps) {
+ if (pages.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/components/Search/SearchSuggestions.tsx b/components/Search/SearchSuggestions.tsx
index e2b9947af..ad1eda2d5 100644
--- a/components/Search/SearchSuggestions.tsx
+++ b/components/Search/SearchSuggestions.tsx
@@ -17,10 +17,14 @@ interface SearchSuggestionsProps {
suggestions?: SearchSuggestion[];
hasLocalSuggestions?: boolean;
clearSearchHistory?: () => void;
+ /** Cap on rendered rows. Defaults to 7 (search modal results). */
+ maxResults?: number;
+ /** When false, omit the Recent / Clear all header (caller owns chrome). */
+ showRecentHeader?: boolean;
}
-// Maximum number of search results to display
-const MAX_RESULTS = 7;
+// Maximum number of search results to display by default
+const DEFAULT_MAX_RESULTS = 7;
// Maximum length for titles before truncating
const MAX_TITLE_LENGTH = 100;
@@ -40,6 +44,8 @@ export function SearchSuggestions({
suggestions = [],
hasLocalSuggestions = false,
clearSearchHistory,
+ maxResults = DEFAULT_MAX_RESULTS,
+ showRecentHeader = true,
}: SearchSuggestionsProps) {
const [erroredSuggestions, setErroredSuggestions] = useState>(new Set());
@@ -275,7 +281,7 @@ export function SearchSuggestions({
return false;
}
})
- .slice(0, MAX_RESULTS); // Limit to maximum number of results
+ .slice(0, maxResults); // Limit to maximum number of results
// Group suggestions by recent vs search results for inline mode
const recentSuggestions = safeSuggestions.filter((s) => s.isRecent);
@@ -291,23 +297,25 @@ export function SearchSuggestions({
{/* Local suggestions section */}
{showSuggestionsOnFocus && !query && hasLocalSuggestions && (
-
-
- Recent
-
-
-
+
+ Recent
+
+
+
+ )}
{safeSuggestions.map(renderSuggestion)}
diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx
index ca791ab23..5ff1a72a3 100644
--- a/components/work/WorkHeader/WorkHeaderGrant.tsx
+++ b/components/work/WorkHeader/WorkHeaderGrant.tsx
@@ -84,6 +84,8 @@ export function WorkHeaderGrant({
) : undefined;
const activityCount = activity.count;
+ const activityCountLabel =
+ activityCount > 0 && activity.hasMore ? `${activityCount}+` : activityCount;
const grantTabs = [
{ id: 'details' as const, label: 'Details' },
@@ -119,7 +121,7 @@ export function WorkHeaderGrant({
: 'bg-gray-100 text-gray-600'
}`}
>
- {activityCount}
+ {activityCountLabel}
)}
diff --git a/hooks/useActivityFeed.ts b/hooks/useActivityFeed.ts
index cb45a3176..a663c3b45 100644
--- a/hooks/useActivityFeed.ts
+++ b/hooks/useActivityFeed.ts
@@ -56,9 +56,12 @@ export function useActivityFeed({ scope, grantId }: UseActivityFeedOptions = {})
scope,
grantId,
});
- setEntries((prev) => [...prev, ...result.entries]);
+ setEntries((prev) => {
+ const next = [...prev, ...result.entries];
+ setCount(next.length);
+ return next;
+ });
setHasMore(result.hasMore);
- setCount(result.count);
pageRef.current = nextPage;
} catch (error) {
console.error('Error loading more activity:', error);
diff --git a/hooks/useFundTabs.ts b/hooks/useFundTabs.tsx
similarity index 57%
rename from hooks/useFundTabs.ts
rename to hooks/useFundTabs.tsx
index 78c44293b..a38bf315d 100644
--- a/hooks/useFundTabs.ts
+++ b/hooks/useFundTabs.tsx
@@ -2,16 +2,31 @@
import { useMemo } from 'react';
import { usePathname, useRouter } from 'next/navigation';
-import { ArrowDownCircle, ArrowUpCircle } from 'lucide-react';
+import { FileText, Waves, type LucideIcon, type LucideProps } from 'lucide-react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faBullhorn } from '@fortawesome/free-solid-svg-icons';
-export type FundTab = 'grants' | 'proposals';
+/** Sized via Tabs' className to match Lucide stroke icons in the pills. */
+function BullhornIcon({ className }: LucideProps) {
+ return ;
+}
+
+export type FundTab = 'grants' | 'proposals' | 'activity';
export const FUND_TABS = [
+ {
+ id: 'activity' as const,
+ label: 'Activity',
+ href: '/fund/activity',
+ icon: Waves,
+ iconClassName: 'w-5 h-5',
+ activeClassName: 'text-indigo-600 border-b-indigo-600',
+ },
{
id: 'grants' as const,
- label: 'Funding Opportunities',
+ label: 'Request for Proposals',
href: '/fund',
- icon: ArrowDownCircle,
+ icon: BullhornIcon as LucideIcon,
iconClassName: 'w-5 h-5',
activeClassName: 'text-emerald-600 border-b-emerald-600',
},
@@ -19,7 +34,7 @@ export const FUND_TABS = [
id: 'proposals' as const,
label: 'Proposals',
href: '/fund/proposals',
- icon: ArrowUpCircle,
+ icon: FileText,
iconClassName: 'w-5 h-5',
activeClassName: 'text-primary-600 border-b-primary-600',
},
@@ -29,10 +44,12 @@ export function useFundTabs() {
const pathname = usePathname();
const router = useRouter();
- const isFundPage = pathname === '/fund' || pathname === '/fund/proposals';
+ const isFundPage =
+ pathname === '/fund' || pathname === '/fund/proposals' || pathname === '/fund/activity';
const activeTab = useMemo((): FundTab => {
if (pathname === '/fund/proposals') return 'proposals';
+ if (pathname === '/fund/activity') return 'activity';
return 'grants';
}, [pathname]);
diff --git a/hooks/useSearchSuggestions.ts b/hooks/useSearchSuggestions.ts
index b7ce55c65..1634f4682 100644
--- a/hooks/useSearchSuggestions.ts
+++ b/hooks/useSearchSuggestions.ts
@@ -1,7 +1,10 @@
import { useState, useEffect, useMemo } from 'react';
import { SearchService } from '@/services/search.service';
import { SearchSuggestion } from '@/types/search';
-import { getSearchHistory, SEARCH_HISTORY_KEY } from '@/utils/searchHistory';
+import {
+ getSearchHistory,
+ clearSearchHistory as clearStoredSearchHistory,
+} from '@/utils/searchHistory';
import { EntityType } from '@/types/search';
interface UseSearchSuggestionsConfig {
@@ -154,7 +157,7 @@ export function useSearchSuggestions({
// Clear all search history
const clearSearchHistory = () => {
if (!includeLocalSuggestions) return;
- localStorage.removeItem(SEARCH_HISTORY_KEY);
+ clearStoredSearchHistory();
setLocalSuggestions([]);
};
diff --git a/services/activity.service.ts b/services/activity.service.ts
index 9e8543825..8f34eaf7e 100644
--- a/services/activity.service.ts
+++ b/services/activity.service.ts
@@ -1,5 +1,10 @@
import { ApiClient } from './client';
-import { FeedEntry, FeedApiResponse, transformFeedEntry, RawApiFeedEntry } from '@/types/feed';
+import {
+ FeedEntry,
+ ActivityFeedApiResponse,
+ transformFeedEntry,
+ RawApiFeedEntry,
+} from '@/types/feed';
export type ActivityDocumentType = 'PREREGISTRATION' | 'GRANT' | 'DISCUSSION';
@@ -17,7 +22,7 @@ export interface GetActivityParams {
export class ActivityService {
private static readonly BASE_PATH = '/api/activity_feed';
- private static readonly DEFAULT_PAGE_SIZE = 25;
+ private static readonly DEFAULT_PAGE_SIZE = 20;
static async getActivity(params?: GetActivityParams): Promise<{
entries: FeedEntry[];
@@ -37,7 +42,7 @@ export class ActivityService {
const qs = queryParams.toString();
const url = `${this.BASE_PATH}/${qs ? `?${qs}` : ''}`;
try {
- const response = await ApiClient.get(url);
+ const response = await ApiClient.get(url);
const entries = response.results
.map((entry: RawApiFeedEntry) => {
@@ -49,7 +54,7 @@ export class ActivityService {
})
.filter((e): e is FeedEntry => e !== null);
- return { entries, hasMore: !!response.next, count: response.count ?? entries.length };
+ return { entries, hasMore: !!response.next, count: entries.length };
} catch (error) {
console.error('Error fetching activity feed:', error);
return { entries: [], hasMore: false, count: 0 };
diff --git a/types/feed.ts b/types/feed.ts
index 7c022574f..cbecf756d 100644
--- a/types/feed.ts
+++ b/types/feed.ts
@@ -2,17 +2,25 @@ import { AuthorProfile, transformAuthorProfile } from './authorProfile';
import { ContentMetrics } from './metrics';
import { Topic, transformTopic } from './topic';
import { createTransformer, BaseTransformed } from './transformer';
-import { Work, transformPaper, transformPost, FundingRequest, ContentType } from './work';
+import {
+ Work,
+ transformPaper,
+ transformPost,
+ FundingRequest,
+ ContentType,
+ type WorkGrantSummary,
+} from './work';
+import { mapApiDocumentTypeToClientType, type ApiDocumentType } from '@/utils/contentTypeMapping';
import { Bounty, BountyWithComment, transformBounty } from './bounty';
import { Comment, CommentType, ContentFormat, transformComment } from './comment';
import { Fundraise, transformFundraise, Application, transformApplication } from './funding';
import { Journal } from './journal';
import { UserVoteType } from './reaction';
-import { User } from './user';
import { stripHtml } from '@/utils/stringUtils';
import { Tip } from './tip';
import { FOUNDATION_USER_ID } from '@/config/constants';
import { GrantStatus } from './grant';
+import { deriveActivityContext } from '@/components/Activity/lib/deriveActivityContext';
export type FeedActionType = 'contribute' | 'open' | 'publish' | 'post';
@@ -28,6 +36,29 @@ export interface ParentCommentPreview {
parentComment?: ParentCommentPreview | undefined; // Add recursive field
}
+function transformFundingActivityRecipient(recipients: unknown): AuthorProfile | undefined {
+ const first = Array.isArray(recipients) ? recipients[0] : undefined;
+ if (!first || typeof first !== 'object') return undefined;
+
+ const recipientUser = (first as { recipient_user?: Record }).recipient_user;
+ if (!recipientUser || typeof recipientUser !== 'object') return undefined;
+
+ try {
+ return transformAuthorProfile(recipientUser);
+ } catch {
+ return undefined;
+ }
+}
+
+function transformFundingActivityFunder(funder: unknown): AuthorProfile | undefined {
+ if (!funder || typeof funder !== 'object') return undefined;
+ try {
+ return transformAuthorProfile(funder as Record);
+ } catch {
+ return undefined;
+ }
+}
+
// Recursive helper function to transform nested parent comments
const transformNestedParentComment = (rawParent: any): ParentCommentPreview | undefined => {
if (!rawParent) {
@@ -141,7 +172,6 @@ export interface FeedCommentContent extends BaseFeedContent {
objectId: number;
};
};
- hasBounties?: boolean;
isRemoved?: boolean;
relatedDocumentId?: number | string;
relatedDocumentContentType?: ContentType;
@@ -205,6 +235,18 @@ export interface FeedGrantContent extends BaseFeedContent {
isExpired?: boolean;
}
+export type FundingActivitySourceType = 'BOUNTY_PAYOUT' | 'TIP_REVIEW';
+
+export interface FeedFundingActivityContent extends BaseFeedContent {
+ contentType: 'FUNDINGACTIVITY';
+ sourceType: FundingActivitySourceType;
+ totalAmount: number;
+ totalUsdCents: number;
+ totalUsd: number;
+ activityDate?: string;
+ recipient?: AuthorProfile;
+}
+
// Update the Content union type to include the base interface
export type Content =
| FeedPostContent
@@ -212,7 +254,8 @@ export type Content =
| FeedBountyContent
| FeedCommentContent
| FeedApplicationContent
- | FeedGrantContent;
+ | FeedGrantContent
+ | FeedFundingActivityContent;
export type FeedContentType =
| 'PAPER'
@@ -223,7 +266,8 @@ export type FeedContentType =
| 'APPLICATION'
| 'GRANT'
| 'USDFUNDRAISECONTRIBUTION'
- | 'PURCHASE';
+ | 'PURCHASE'
+ | 'FUNDINGACTIVITY';
export interface ExternalMetrics {
score: number;
@@ -280,6 +324,17 @@ export interface AssociatedGrant {
numApplicants: number;
}
+export type ActivityContext =
+ | 'tip_review'
+ | 'bounty_payout'
+ | 'fundraise_contribution'
+ | 'bounty_opened'
+ | 'bounty_contributed'
+ | 'peer_review_published'
+ | 'comment_published'
+ | 'grant_opened'
+ | 'proposal_submitted';
+
export interface JournalPostIds {
grantPostId: number | null;
proposalPostId: number | null;
@@ -307,6 +362,7 @@ export interface FeedEntry {
externalMetrics?: ExternalMetrics;
nonprofit?: Nonprofit;
associatedGrants?: AssociatedGrant[];
+ activityContext?: ActivityContext;
journalPostIds?: JournalPostIds;
searchMetadata?: {
highlightedTitle?: string;
@@ -344,6 +400,7 @@ export interface RawApiFeedEntry {
base_wallet_address: string;
};
hot_score_v2?: number;
+ related_work?: any;
risk_score?: number | null;
associated_grants?: Array<{
id: number;
@@ -442,6 +499,12 @@ export interface FeedApiResponse {
results: RawApiFeedEntry[];
}
+export interface ActivityFeedApiResponse {
+ next: string | null;
+ previous: string | null;
+ results: RawApiFeedEntry[];
+}
+
/**
* Safely extracts the unified document ID from a content object.
*
@@ -471,6 +534,78 @@ export function getUnifiedDocumentId(content_object: any): string | undefined {
export type TransformedContent = Content & BaseTransformed;
export type TransformedFeedEntry = FeedEntry & BaseTransformed;
+function transformActivityRelatedWorkGrant(rawGrant: unknown): WorkGrantSummary | undefined {
+ if (!rawGrant || typeof rawGrant !== 'object') return undefined;
+
+ const grant = rawGrant as {
+ status?: string;
+ organization?: string;
+ amount?: { usd?: number; rsc?: number | null };
+ application_count?: number;
+ end_date?: string | null;
+ };
+
+ if (typeof grant.amount !== 'object' || grant.amount === null) {
+ return undefined;
+ }
+
+ return {
+ status: grant.status ?? '',
+ organization: grant.organization ?? '',
+ amount: {
+ usd: grant.amount.usd ?? 0,
+ rsc: grant.amount.rsc ?? null,
+ },
+ numApplicants: grant.application_count ?? 0,
+ endDate: grant.end_date ?? undefined,
+ };
+}
+
+function transformActivityRelatedWork(raw: any): Work | undefined {
+ if (!raw) return undefined;
+
+ const contentType =
+ mapApiDocumentTypeToClientType(raw.document_type as ApiDocumentType) ?? 'post';
+
+ let fundraise: Fundraise | undefined;
+ if (raw.fundraise) {
+ try {
+ fundraise = transformFundraise(raw.fundraise);
+ } catch (error) {
+ console.error('Error transforming activity related_work fundraise:', error);
+ }
+ }
+
+ const hubTopic = raw.hub
+ ? raw.hub.id
+ ? transformTopic(raw.hub)
+ : { id: 0, name: raw.hub.name || '', slug: raw.hub.slug || '' }
+ : undefined;
+
+ return {
+ id: raw.id,
+ slug: raw.slug || '',
+ title: stripHtml(raw.title || ''),
+ contentType,
+ createdDate: raw.created_date || '',
+ abstract: '',
+ authors: Array.isArray(raw.authors)
+ ? raw.authors.map((author: unknown) => ({
+ authorProfile: transformAuthorProfile(author),
+ isCorresponding: false,
+ position: 'middle' as const,
+ }))
+ : [],
+ topics: hubTopic ? [hubTopic] : [],
+ formats: [],
+ figures: [],
+ unifiedDocumentId: raw.unified_document_id,
+ image: raw.image_url ?? undefined,
+ fundraise,
+ grantSummary: raw.grant ? transformActivityRelatedWorkGrant(raw.grant) : undefined,
+ };
+}
+
// Updated transformFeedEntry function to use the simplified Content type
export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
if (!feedEntry) {
@@ -743,8 +878,16 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
// Transform the comment to get score and other properties
const transformedComment = transformComment(commentData);
- const hasBounties =
- Array.isArray(content_object.bounties) && content_object.bounties.length > 0;
+ const bounties = Array.isArray(content_object.bounties)
+ ? content_object.bounties.map((bounty: Record) =>
+ transformBounty(bounty, { ignoreBaseAmount: true })
+ )
+ : undefined;
+
+ const rawCommentType = content_object.comment_type as string | undefined;
+ const normalizedCommentType = (
+ rawCommentType === 'PEER_REVIEW' ? 'REVIEW' : rawCommentType
+ ) as CommentType;
// Create a FeedCommentContent object
const commentContent: FeedCommentContent = {
@@ -755,12 +898,12 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
updatedDate: content_object.updated_date || action_date || created_date,
createdBy: transformAuthorProfile(author || content_object.author),
isRemoved: content_object.is_removed,
- hasBounties,
+ ...(bounties?.length ? { bounties } : {}),
comment: {
id: content_object.id,
content: content_object.comment_content_json,
contentFormat: (content_object.comment_content_type as ContentFormat) || 'QUILL_EDITOR',
- commentType: content_object.comment_type as CommentType,
+ commentType: normalizedCommentType,
score: transformedComment.score || 0,
reviewScore: transformedComment.reviewScore || 0,
isAssessed: transformedComment.isAssessed ?? false,
@@ -961,14 +1104,16 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
case 'USDFUNDRAISECONTRIBUTION':
contentType = content_type as 'PURCHASE' | 'USDFUNDRAISECONTRIBUTION';
try {
+ const relatedWorkRaw = feedEntry.related_work;
const contributionEntry: FeedPostContent = {
- id: content_object.post_id ?? content_object.id ?? id,
- unifiedDocumentId: content_object.unified_document_id,
+ id: content_object.post_id ?? relatedWorkRaw?.id ?? id,
+ unifiedDocumentId:
+ content_object.unified_document_id ?? relatedWorkRaw?.unified_document_id,
contentType: 'PREREGISTRATION',
createdDate: action_date || created_date,
textPreview: '',
- slug: content_object.proposal_slug || '',
- title: stripHtml(content_object.proposal_title || ''),
+ slug: content_object.proposal_slug || relatedWorkRaw?.slug || '',
+ title: stripHtml(content_object.proposal_title || relatedWorkRaw?.title || ''),
authors: [transformAuthorProfile(author)],
topics: content_object.hub
? [
@@ -994,6 +1139,42 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
}
break;
+ case 'FUNDINGACTIVITY':
+ contentType = 'FUNDINGACTIVITY';
+ try {
+ const relatedWorkRaw = feedEntry.related_work;
+
+ const totalAmountRaw = content_object.total_amount;
+ const totalAmount =
+ typeof totalAmountRaw === 'string'
+ ? parseFloat(totalAmountRaw) || 0
+ : totalAmountRaw || 0;
+
+ const funder = transformFundingActivityFunder(content_object.funder);
+
+ const fundingActivityContent: FeedFundingActivityContent = {
+ id: content_object.id ?? id,
+ unifiedDocumentId:
+ content_object.unified_document_id ?? relatedWorkRaw?.unified_document_id,
+ contentType: 'FUNDINGACTIVITY',
+ createdDate: action_date || created_date,
+ createdBy: funder ?? transformAuthorProfile(author),
+ sourceType: content_object.source_type as FundingActivitySourceType,
+ totalAmount,
+ totalUsdCents: content_object.total_usd_cents || 0,
+ totalUsd:
+ content_object.total_usd ??
+ (content_object.total_usd_cents ? content_object.total_usd_cents / 100 : 0),
+ activityDate: content_object.activity_date,
+ recipient: transformFundingActivityRecipient(content_object.recipients),
+ };
+ content = fundingActivityContent;
+ } catch (error) {
+ console.error('Error transforming FUNDINGACTIVITY:', error);
+ throw new Error(`Failed to transform FUNDINGACTIVITY: ${error}`);
+ }
+ break;
+
default:
// For unsupported types, try to transform to a Work
console.warn(
@@ -1036,6 +1217,11 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
}
// Complete the feed entry
+ const activityRelatedWork = transformActivityRelatedWork(feedEntry.related_work);
+ if (activityRelatedWork) {
+ relatedWork = activityRelatedWork;
+ }
+
return {
...baseFeedEntry,
content,
@@ -1106,6 +1292,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
baseWalletAddress: nonprofit.base_wallet_address,
}
: undefined,
+ activityContext: deriveActivityContext(feedEntry),
} as FeedEntry;
};
diff --git a/types/work.ts b/types/work.ts
index 054e373c1..f9dae199c 100644
--- a/types/work.ts
+++ b/types/work.ts
@@ -133,6 +133,7 @@ export interface Work {
aiPeerReview?: ProposalReview | null;
enrichments?: Enrichment[];
linkedGrant?: LinkedGrant | null;
+ grantSummary?: WorkGrantSummary;
moderationStatus?: ModerationStatus;
isPublic?: boolean;
}
@@ -151,6 +152,15 @@ export interface LinkedGrant {
applicantCount: number;
}
+/** Slim grant metadata attached to activity feed related_work */
+export interface WorkGrantSummary {
+ status: string;
+ organization: string;
+ amount: { usd: number; rsc: number | null };
+ numApplicants: number;
+ endDate?: string;
+}
+
export interface FundingRequest extends Work {
type: 'funding_request';
contentType: 'funding_request';
diff --git a/utils/searchHistory.ts b/utils/searchHistory.ts
index ac7a79e64..905052948 100644
--- a/utils/searchHistory.ts
+++ b/utils/searchHistory.ts
@@ -26,3 +26,7 @@ export const saveSearchHistory = (items: SearchSuggestion[]) => {
console.error('Error saving to localStorage:', error);
}
};
+
+export const clearSearchHistory = () => {
+ saveSearchHistory([]);
+};