+
+
-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..be97f4138 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,106 @@ 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;
+
+ // Bounty payouts and Foundation tips read as the recipient earning — the
+ // person who received the money is the interesting subject.
+ if (recipient && (content.sourceType === 'BOUNTY_PAYOUT' || isFoundationProfile(actor))) {
+ return { actor: recipient, verb: 'earned', isEarning: true };
+ }
+
+ if (content.sourceType === 'BOUNTY_PAYOUT') {
+ return { actor, verb: 'awarded bounty', 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 'contributed to';
+
+ if (entry.contentType === 'BOUNTY') {
+ return { actor, verb: '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 +151,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 +241,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 +273,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 +358,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/FundActivityPageContent.tsx b/components/Funding/FundActivityPageContent.tsx
new file mode 100644
index 000000000..cbc6be4a6
--- /dev/null
+++ b/components/Funding/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/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
deleted file mode 100644
index a411457f8..000000000
--- a/components/Funding/FundingBannerSkeleton.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { ResearchCoinSnapshotSkeleton } from '@/components/ResearchCoin/ResearchCoinSnapshot';
-
-interface FundingBannerSkeletonProps {
- showTabs?: boolean;
-}
-
-export function FundingBannerSkeleton({ showTabs = false }: FundingBannerSkeletonProps) {
- return (
-
-
-
-
-
-
- {showTabs && (
-
- )}
-
-
- );
-}
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/HomeTabs.tsx b/components/Funding/HomeTabs.tsx
new file mode 100644
index 000000000..de1b63e62
--- /dev/null
+++ b/components/Funding/HomeTabs.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { FeedTabs } from '@/components/Feed/FeedTabs';
+import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel';
+import { useFundTabs } from '@/hooks/useFundTabs';
+
+/**
+ * Homepage hub tabs (Activity / Fund / Proposals). Pill style matches the old
+ * for-you feed; the sentinel lifts a sticky copy into the TopBar on scroll.
+ */
+export function HomeTabs() {
+ const { tabs, highlightedTab, handleTabChange } = useFundTabs();
+ const tabsSentinelRef = useContentTabsVisibilitySentinel();
+
+ return (
+
+
+
+ );
+}
diff --git a/components/Funding/MarketplaceCards.tsx b/components/Funding/MarketplaceCards.tsx
deleted file mode 100644
index fa7357a94..000000000
--- a/components/Funding/MarketplaceCards.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-'use client';
-
-import { Tabs } from '@/components/ui/Tabs';
-import { useUser } from '@/contexts/UserContext';
-import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel';
-import { FUND_TABS, type FundTab } from '@/hooks/useFundTabs';
-
-export type MarketplaceTab = FundTab;
-
-interface MarketplaceCardsProps {
- selected?: MarketplaceTab;
-}
-
-export function MarketplaceCards({ selected = 'grants' }: MarketplaceCardsProps) {
- const { user, isLoading: isLoadingUser } = useUser();
- // Pull the tabs up only when the panel is or will be taller (snapshot visible).
- // Logged-out users see just the CTA (shorter panel), so no offset is needed.
- const snapshotVisible = isLoadingUser || !!user;
- const tabsSentinelRef = useContentTabsVisibilitySentinel();
-
- return (
-
- {}}
- variant="primary"
- className="mt-4"
- />
-
- );
-}
diff --git a/components/Funding/RecentlyVisitedCard.tsx b/components/Funding/RecentlyVisitedCard.tsx
new file mode 100644
index 000000000..c6f7530a6
--- /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: isCleared || (isLoading && pages.length === 0) ? [] : 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/landing/LandingPageHero.tsx b/components/landing/LandingPageHero.tsx
index 3feee2a37..f293dc4b8 100644
--- a/components/landing/LandingPageHero.tsx
+++ b/components/landing/LandingPageHero.tsx
@@ -16,7 +16,7 @@ export function LandingPageHero() {
};
const handleExplore = () => {
- router.push('/popular');
+ router.push('/');
};
return (
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/useFeedTabs.ts b/hooks/useFeedTabs.ts
index 384def73d..57caa33ed 100644
--- a/hooks/useFeedTabs.ts
+++ b/hooks/useFeedTabs.ts
@@ -18,7 +18,9 @@ export const useFeedTabs = (onBeforeNavigate?: () => void) => {
const isTopicPage = pathname.startsWith('/topic/');
const isJournalPage = pathname.startsWith('/journal');
- const isHomeFeedPage = ['/', '/following', '/latest', '/popular', '/for-you', '/feed'].includes(
+ // Legacy research-feed routes (redirected to `/` for homepage hub).
+ // `/` is now the Activity hub tab — not a research feed.
+ const isHomeFeedPage = ['/following', '/latest', '/popular', '/for-you', '/feed'].includes(
pathname
);
diff --git a/hooks/useFundTabs.ts b/hooks/useFundTabs.ts
deleted file mode 100644
index 78c44293b..000000000
--- a/hooks/useFundTabs.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-'use client';
-
-import { useMemo } from 'react';
-import { usePathname, useRouter } from 'next/navigation';
-import { ArrowDownCircle, ArrowUpCircle } from 'lucide-react';
-
-export type FundTab = 'grants' | 'proposals';
-
-export const FUND_TABS = [
- {
- id: 'grants' as const,
- label: 'Funding Opportunities',
- href: '/fund',
- icon: ArrowDownCircle,
- iconClassName: 'w-5 h-5',
- activeClassName: 'text-emerald-600 border-b-emerald-600',
- },
- {
- id: 'proposals' as const,
- label: 'Proposals',
- href: '/fund/proposals',
- icon: ArrowUpCircle,
- iconClassName: 'w-5 h-5',
- activeClassName: 'text-primary-600 border-b-primary-600',
- },
-];
-
-export function useFundTabs() {
- const pathname = usePathname();
- const router = useRouter();
-
- const isFundPage = pathname === '/fund' || pathname === '/fund/proposals';
-
- const activeTab = useMemo((): FundTab => {
- if (pathname === '/fund/proposals') return 'proposals';
- return 'grants';
- }, [pathname]);
-
- const tabs = useMemo(() => FUND_TABS, []);
-
- const handleTabChange = (tab: string, e?: React.MouseEvent) => {
- if (tab === activeTab) {
- e?.preventDefault();
- return;
- }
-
- const href = FUND_TABS.find((t) => t.id === tab)?.href;
- if (!href) return;
-
- if (e && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
- e.preventDefault();
- router.push(href);
- } else if (!e) {
- router.push(href);
- }
- };
-
- return {
- tabs,
- activeTab,
- highlightedTab: activeTab,
- handleTabChange,
- isFundPage,
- };
-}
diff --git a/hooks/useFundTabs.tsx b/hooks/useFundTabs.tsx
new file mode 100644
index 000000000..8a099685b
--- /dev/null
+++ b/hooks/useFundTabs.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { useMemo } from 'react';
+import { usePathname, useRouter } from 'next/navigation';
+import { FileText, Waves, type LucideIcon, type LucideProps } from 'lucide-react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faBullhorn } from '@fortawesome/free-solid-svg-icons';
+import { useScrollContainer } from '@/contexts/ScrollContainerContext';
+
+/** Sized via PillTabs' className to match Lucide stroke icons in the pills. */
+function BullhornIcon({ className }: LucideProps) {
+ return
;
+}
+
+export type FundTab = 'activity' | 'fund' | 'proposals';
+
+/** Routes that make up the homepage hub — all of them highlight "Home" in the nav. */
+export const HOME_TAB_PATHS = ['/', '/fund', '/fund/proposals'];
+
+export const isHomeTabPath = (pathname: string) => HOME_TAB_PATHS.includes(pathname);
+
+export const FUND_TABS = [
+ {
+ id: 'activity' as const,
+ label: 'Activity',
+ href: '/',
+ icon: Waves,
+ scroll: false,
+ },
+ {
+ id: 'fund' as const,
+ label: 'Fund',
+ href: '/fund',
+ icon: BullhornIcon as LucideIcon,
+ scroll: false,
+ },
+ {
+ id: 'proposals' as const,
+ label: 'Proposals',
+ href: '/fund/proposals',
+ icon: FileText,
+ scroll: false,
+ },
+];
+
+/** Homepage hub: Activity / Fund / Proposals (shared shell + FundSidebar). */
+export function useFundTabs() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const scrollContainerRef = useScrollContainer();
+
+ const isFundPage = isHomeTabPath(pathname);
+
+ const activeTab = useMemo((): FundTab => {
+ if (pathname === '/fund/proposals') return 'proposals';
+ if (pathname === '/fund') return 'fund';
+ return 'activity';
+ }, [pathname]);
+
+ const tabs = useMemo(() => FUND_TABS, []);
+
+ const scrollToTop = () => {
+ const container = scrollContainerRef?.current;
+ if (container) {
+ container.scrollTop = 0;
+ } else {
+ window.scrollTo(0, 0);
+ }
+ };
+
+ const handleTabChange = (tab: string, e?: React.MouseEvent) => {
+ if (tab === activeTab) {
+ e?.preventDefault();
+ return;
+ }
+
+ const href = FUND_TABS.find((t) => t.id === tab)?.href;
+ if (!href) return;
+
+ if (e && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
+ e.preventDefault();
+ scrollToTop();
+ router.push(href, { scroll: false });
+ } else if (!e) {
+ scrollToTop();
+ router.push(href, { scroll: false });
+ }
+ };
+
+ return {
+ tabs,
+ activeTab,
+ highlightedTab: activeTab,
+ handleTabChange,
+ isFundPage,
+ };
+}
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/navigation.ts b/utils/navigation.ts
index 74dac91e5..865bcf316 100644
--- a/utils/navigation.ts
+++ b/utils/navigation.ts
@@ -124,27 +124,15 @@ export function handleMissingSlugRedirect(
}
/**
- * Handles redirection to trending page if user is authorized
- * @param isUserLoggedIn Whether the user is logged in
- * @param searchParams Optional search parameters to preserve in the redirect
+ * Redirects into the homepage Activity hub.
+ * Preserves search parameters when provided.
*/
-export function handleTrendingRedirect(isUserLoggedIn: boolean, searchParams?: URLSearchParams) {
- if (isUserLoggedIn) {
- let redirectUrl = '/for-you';
+export function handleTrendingRedirect(_isUserLoggedIn: boolean, searchParams?: URLSearchParams) {
+ let redirectUrl = '/';
- // Preserve search parameters if provided
- if (searchParams && searchParams.toString()) {
- redirectUrl += `?${searchParams.toString()}`;
- }
-
- redirect(redirectUrl);
- } else {
- let popularUrl = '/popular';
-
- if (searchParams && searchParams.toString()) {
- popularUrl += `?${searchParams.toString()}`;
- }
-
- redirect(popularUrl);
+ if (searchParams && searchParams.toString()) {
+ redirectUrl += `?${searchParams.toString()}`;
}
+
+ redirect(redirectUrl);
}
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([]);
+};