From a0bb18babde56ffcf4454a0dbe84ab2542896880 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Mon, 15 Jun 2026 22:24:25 +0300 Subject: [PATCH 1/4] Refactor activity feed cards for financial events and related-work API updates --- app/activity/page.tsx | 21 +- app/grant/[id]/[slug]/page.tsx | 7 +- components/Activity/ActivityCardFull.tsx | 193 +++++++------- components/Activity/ActivityCardHeader.tsx | 66 +++++ components/Activity/ActivityCardSkeleton.tsx | 39 +++ .../Activity/ActivityHeaderActionText.tsx | 43 +++ components/Activity/ActivityWorkPanel.tsx | 87 +++++++ components/Activity/ReviewScoreStars.tsx | 39 +++ .../Activity/lib/activityWorkContext.ts | 119 +++++++++ .../Activity/lib/deriveActivityContext.ts | 40 +++ components/Activity/lib/feedEntryAdapters.ts | 146 ++++++++++- .../Activity/slots/ActivityBountySlot.tsx | 181 +++++++++++++ .../Activity/slots/ActivityCommentSlot.tsx | 135 ++++++++++ .../Activity/slots/ActivityFundraiseSlot.tsx | 149 +++++++++++ .../Activity/slots/ActivityGrantSlot.tsx | 80 ++++++ components/Funding/ActivityCard.tsx | 31 +-- components/Funding/GrantContentSwitcher.tsx | 31 +-- components/modals/CommentDetailsModal.tsx | 88 +++++++ .../work/WorkHeader/WorkHeaderGrant.tsx | 4 +- hooks/useActivityFeed.ts | 7 +- services/activity.service.ts | 13 +- types/feed.ts | 244 +++++++++++++++++- types/work.ts | 10 + 23 files changed, 1579 insertions(+), 194 deletions(-) create mode 100644 components/Activity/ActivityCardHeader.tsx create mode 100644 components/Activity/ActivityCardSkeleton.tsx create mode 100644 components/Activity/ActivityHeaderActionText.tsx create mode 100644 components/Activity/ActivityWorkPanel.tsx create mode 100644 components/Activity/ReviewScoreStars.tsx create mode 100644 components/Activity/lib/activityWorkContext.ts create mode 100644 components/Activity/lib/deriveActivityContext.ts create mode 100644 components/Activity/slots/ActivityBountySlot.tsx create mode 100644 components/Activity/slots/ActivityCommentSlot.tsx create mode 100644 components/Activity/slots/ActivityFundraiseSlot.tsx create mode 100644 components/Activity/slots/ActivityGrantSlot.tsx create mode 100644 components/modals/CommentDetailsModal.tsx diff --git a/app/activity/page.tsx b/app/activity/page.tsx index 437821147..e62992c4e 100644 --- a/app/activity/page.tsx +++ b/app/activity/page.tsx @@ -8,6 +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 { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; import { useActivityFeed, ActivityTab } from '@/hooks/useActivityFeed'; import { ActivityScope } from '@/services/activity.service'; import { GrantService } from '@/services/grant.service'; @@ -100,27 +101,13 @@ export default function ActivityPage() {
{tabsElement} -
+
{entries.map((entry) => ( ))} - {(isLoading || isLoadingMore) && ( -
- {[...Array(15)].map((_, i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- )} + {(isLoading || isLoadingMore) && + [...Array(6)].map((_, i) => )} {!isLoading && !isLoadingMore && entries.length === 0 && (
diff --git a/app/grant/[id]/[slug]/page.tsx b/app/grant/[id]/[slug]/page.tsx index ca91b7dd5..6a6e0b516 100644 --- a/app/grant/[id]/[slug]/page.tsx +++ b/app/grant/[id]/[slug]/page.tsx @@ -42,12 +42,7 @@ export default async function GrantSlugPage({ params }: Props) { const grantId = grant?.id ?? undefined; return ( - + {grant?.description && } diff --git a/components/Activity/ActivityCardFull.tsx b/components/Activity/ActivityCardFull.tsx index 48a0d1da8..ff47c77ed 100644 --- a/components/Activity/ActivityCardFull.tsx +++ b/components/Activity/ActivityCardFull.tsx @@ -1,121 +1,110 @@ 'use client'; -import { FC, useState } from 'react'; -import Link from 'next/link'; -import { Star, ChevronDown, ChevronUp } from 'lucide-react'; -import { Avatar } from '@/components/ui/Avatar'; -import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; -import { CommentReadOnly } from '@/components/Comment/CommentReadOnly'; -import { ContributionAmount } from './ContributionAmount'; -import { FeedEntryIcon } from './FeedEntryIcon'; -import { GrantFundingAmount } from './GrantFundingAmount'; +import { FC } from 'react'; +import { ActivityCardHeader } from './ActivityCardHeader'; +import { ActivityWorkPanel } from './ActivityWorkPanel'; +import { ActivityFundraiseSlot } from './slots/ActivityFundraiseSlot'; +import { ActivityBountySlot } from './slots/ActivityBountySlot'; +import { ActivityGrantSlot } from './slots/ActivityGrantSlot'; +import { ActivityCommentSlot } from './slots/ActivityCommentSlot'; +import { getCommentPreview, getReviewScore } from './lib/feedEntryAdapters'; import { - getActionIcon, - getActionLabel, - getCommentPreview, - getContribution, - getEntryMeta, - getGrantAmount, - getReviewScore, -} from './lib/feedEntryAdapters'; -import { formatTimeAgo } from '@/utils/date'; -import type { FeedEntry } from '@/types/feed'; + getActivityWorkContext, + resolveActivityBodySlot, + shouldShowActivityComment, + type ActivityBodySlot, +} from './lib/activityWorkContext'; +import type { FeedCommentContent, FeedEntry } from '@/types/feed'; interface ActivityCardFullProps { entry: FeedEntry; } +function ActivityBodySlot({ + slot, + work, + reviewScore, + commentPreview, +}: { + slot: ActivityBodySlot; + work: ReturnType; + reviewScore?: number; + commentPreview: ReturnType; +}) { + if (!work) return null; + + switch (slot) { + case 'fundraise': + return ; + case 'bounty': + return ( + + ); + case 'grant': + return ; + default: + return null; + } +} + export const ActivityCardFull: FC = ({ entry }) => { - const { title, author, href } = getEntryMeta(entry); - const [reviewExpanded, setReviewExpanded] = useState(false); + const work = getActivityWorkContext(entry); - 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 commentPreview = getCommentPreview(entry); + const reviewScore = + getReviewScore(entry) ?? entry.metrics?.reviewScore ?? work.fundraise?.reviewMetrics?.avg; - const titleEl = href ? ( - - {title} - - ) : ( - {title} - ); + const slot = resolveActivityBodySlot(entry.activityContext, work, { + isReview: commentPreview?.isReview, + }); + const effectiveSlot: ActivityBodySlot = + slot === 'fundraise' && !work.fundraise + ? 'default' + : slot === 'grant' && !work.grant + ? 'default' + : slot === 'bounty' && !work.bounty + ? 'default' + : slot; - return ( -
-
-
- - - -
-
- {author?.fullName || 'Unknown'} - {actionLabel} - - {reviewScore != null && ( - - - {reviewScore.toFixed(1)} - - )} - {grantAmount && } - {contribution && ( - - )} -
- {titleEl} -
+ const commentEntry = + entry.contentType === 'COMMENT' ? (entry.content as FeedCommentContent) : undefined; - {commentPreview && !commentPreview.isReview && ( -
- -
- )} + const showCommentSlot = shouldShowActivityComment(effectiveSlot, commentPreview); - {commentPreview && commentPreview.isReview && ( -
- - {reviewExpanded && ( -
- -
- )} -
- )} + const fundraiseSlotReviewScore = + effectiveSlot === 'fundraise' && showCommentSlot ? undefined : reviewScore; - - {formatTimeAgo(entry.timestamp)} - -
+ return ( +
+ + + + {showCommentSlot && commentPreview && ( + + )} + +
); }; diff --git a/components/Activity/ActivityCardHeader.tsx b/components/Activity/ActivityCardHeader.tsx new file mode 100644 index 000000000..4001c9467 --- /dev/null +++ b/components/Activity/ActivityCardHeader.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { FC } from 'react'; +import { Avatar } from '@/components/ui/Avatar'; +import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import { ActivityHeaderActionText } from './ActivityHeaderActionText'; +import { ContributionAmount } from './ContributionAmount'; +import { FeedEntryIcon } from './FeedEntryIcon'; +import { GrantFundingAmount } from './GrantFundingAmount'; +import { ReviewScoreStars } from './ReviewScoreStars'; +import { + getActionIcon, + getActivityHeaderMessage, + getContribution, + getGrantAmount, + getReviewScore, +} from './lib/feedEntryAdapters'; +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 grantAmount = getGrantAmount(entry); + const contribution = getContribution(entry); + + return ( +
+
+ + + + + + {reviewScore != null && reviewScore > 0 && ( + + )} + {grantAmount && } + {contribution && ( + + )} + + + {formatTimeAgo(entry.timestamp)} + + +
+
+ ); +}; diff --git a/components/Activity/ActivityCardSkeleton.tsx b/components/Activity/ActivityCardSkeleton.tsx new file mode 100644 index 000000000..0e8047137 --- /dev/null +++ b/components/Activity/ActivityCardSkeleton.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FC } from 'react'; + +export const ActivityCardSkeleton: FC = () => ( +
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); diff --git a/components/Activity/ActivityHeaderActionText.tsx b/components/Activity/ActivityHeaderActionText.tsx new file mode 100644 index 000000000..a978805fb --- /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/ActivityWorkPanel.tsx b/components/Activity/ActivityWorkPanel.tsx new file mode 100644 index 000000000..b898bc3cf --- /dev/null +++ b/components/Activity/ActivityWorkPanel.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { FC, ReactNode } from 'react'; +import Link from 'next/link'; +import { FileText } from 'lucide-react'; +import { ImageSection } from '@/components/Feed/ImageSection'; +import { AuthorList } from '@/components/ui/AuthorList'; +import type { ActivityWorkContext } from './lib/activityWorkContext'; + +interface ActivityWorkPanelProps { + work: ActivityWorkContext; + children?: ReactNode; + footer?: ReactNode; +} + +export const ActivityWorkPanel: FC = ({ work, children, footer }) => { + const { title, href, imageUrl, documentType, authors } = work; + + const imageAlt = title || 'Document image'; + const showAuthors = documentType === 'paper' && authors && authors.length > 0; + + return ( +
+ {imageUrl && ( +
+ +
+ )} + +
+
+ {imageUrl ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ +
+ + {title} + + + {showAuthors && ( + ({ + name: author.fullName, + verified: author.user?.isVerified ?? author.isVerified, + authorUrl: author.id === 0 ? undefined : author.profileUrl, + }))} + size="xs" + className="text-gray-500 font-normal" + delimiter="," + delimiterClassName="ml-0" + showAbbreviatedInMobile + hideExpandButton + /> + )} + + {children} + + {footer &&
{footer}
} +
+
+
+ ); +}; diff --git a/components/Activity/ReviewScoreStars.tsx b/components/Activity/ReviewScoreStars.tsx new file mode 100644 index 000000000..acd4c53c1 --- /dev/null +++ b/components/Activity/ReviewScoreStars.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FC } from 'react'; +import { Star } from 'lucide-react'; +import { cn } from '@/utils/styles'; + +const SIZE_CLASS = { + xs: 'h-3 w-3', + sm: 'h-3.5 w-3.5', + md: 'h-4 w-4', +} as const; + +interface ReviewScoreStarsProps { + score: number; + size?: keyof typeof SIZE_CLASS; + className?: string; +} + +/** Read-only 5-star row; filled count matches review score. */ +export const ReviewScoreStars: FC = ({ score, size = 'sm', className }) => { + if (score <= 0) return null; + + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); +}; diff --git a/components/Activity/lib/activityWorkContext.ts b/components/Activity/lib/activityWorkContext.ts new file mode 100644 index 000000000..0727ebbc0 --- /dev/null +++ b/components/Activity/lib/activityWorkContext.ts @@ -0,0 +1,119 @@ +import { buildWorkUrl } from '@/utils/url'; +import type { ContentType } from '@/types/work'; +import type { + ActivityContext, + FeedBountyContent, + FeedCommentContent, + FeedEntry, +} 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'; + +export type ActivityBodySlot = 'fundraise' | 'bounty' | 'grant' | 'default'; + +export interface ActivityWorkContext { + id: number; + slug: string; + title: string; + href: string; + imageUrl?: string; + documentType: ContentType; + fundraise?: Fundraise; + grant?: WorkGrantSummary; + bounty?: Bounty; + authors?: AuthorProfile[]; + tab?: 'reviews' | 'bounties' | 'conversation'; +} + +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; + } +} + +export function resolveActivityBodySlot( + activityContext?: ActivityContext, + work?: Pick, + options?: { isReview?: boolean } +): ActivityBodySlot { + if (activityContext === 'bounty_opened' || activityContext === 'bounty_contributed') { + return 'bounty'; + } + if (activityContext === 'grant_opened') { + return 'grant'; + } + if ( + activityContext === 'tip_review' || + activityContext === 'bounty_payout' || + activityContext === 'fundraise_contribution' || + activityContext === 'proposal_submitted' + ) { + if (work?.fundraise) return 'fundraise'; + return 'default'; + } + if (activityContext === 'peer_review_published' || options?.isReview) { + if (work?.fundraise) return 'fundraise'; + return 'default'; + } + if (activityContext === 'comment_published' && work?.fundraise) { + return 'fundraise'; + } + return 'default'; +} + +export function shouldShowActivityComment( + slot: ActivityBodySlot, + commentPreview: { isReview: boolean } | null +): boolean { + return Boolean(commentPreview) && slot !== 'bounty' && slot !== 'grant'; +} + +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, + 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..c3c6ea7fa 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -3,6 +3,7 @@ import type { FeedCommentContent, FeedContentType, FeedEntry, + FeedFundingActivityContent, FeedGrantContent, FeedPaperContent, FeedPostContent, @@ -34,17 +35,84 @@ const FEED_TO_CONTENT_TYPE: Partial> = { PAPER: 'paper', }; -export function getActionLabel(entry: FeedEntry): string { +function isPeerReviewTipComment(commentType?: CommentType | string): boolean { + return commentType === 'PEER_REVIEW' || commentType === 'REVIEW'; +} + +export interface ActivityHeaderTarget { + author: AuthorProfile; + suffix?: string; +} + +export interface ActivityHeaderMessage { + actor: AuthorProfile; + verb: string; + target?: ActivityHeaderTarget; +} + +function getFundingActivityMessage(content: FeedFundingActivityContent): ActivityHeaderMessage { + const actor = content.createdBy; + + if (content.sourceType === 'BOUNTY_PAYOUT') { + const recipient = content.recipient; + return { + actor, + verb: recipient ? 'awarded bounty to' : 'awarded bounty', + target: recipient ? { author: recipient } : undefined, + }; + } + + const tippedAuthor = content.tippedComment?.createdBy; + const isPeerReviewTip = isPeerReviewTipComment(content.tippedComment?.commentType); + if (!tippedAuthor) { + return { + actor, + verb: isPeerReviewTip ? 'tipped review' : 'tipped comment', + }; + } + return { + actor, + verb: 'tipped', + target: { + author: tippedAuthor, + suffix: isPeerReviewTip ? "'s review" : "'s comment", + }, + }; +} + +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'; + if (commentContent.bounties?.length) { + return { actor, verb: 'opened a bounty on' }; + } + return { + actor, + verb: COMMENT_ACTION_LABELS[commentContent.comment?.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 +128,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; @@ -120,14 +221,18 @@ export function getEntryMeta(entry: FeedEntry): FeedEntryMeta { export type FeedEntryIconName = 'coins' | 'bell' | 'message' | null; export function getActionIcon(entry: FeedEntry): FeedEntryIconName { - if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { + 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,6 +241,12 @@ export function getActionIcon(entry: FeedEntry): FeedEntryIconName { } export function getReviewScore(entry: FeedEntry): number | undefined { + if (entry.contentType === 'FUNDINGACTIVITY') { + const funding = entry.content as FeedFundingActivityContent; + const tippedComment = funding.tippedComment; + if (!isPeerReviewTipComment(tippedComment?.commentType)) return undefined; + return tippedComment?.review?.score ?? tippedComment?.reviewScore; + } if (entry.contentType !== 'COMMENT') return undefined; const commentContent = entry.content as FeedCommentContent; if (commentContent.comment?.commentType !== 'REVIEW') return undefined; @@ -148,6 +259,13 @@ export interface FeedContribution { } 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 +317,16 @@ export interface FeedCommentPreview { } export function getCommentPreview(entry: FeedEntry): FeedCommentPreview | null { + if (entry.contentType === 'FUNDINGACTIVITY') { + const funding = entry.content as FeedFundingActivityContent; + const tippedComment = funding.tippedComment; + if (!tippedComment?.content) return null; + return { + content: tippedComment.content, + format: tippedComment.contentFormat, + isReview: isPeerReviewTipComment(tippedComment.commentType), + }; + } if (entry.contentType !== 'COMMENT') return null; const { comment } = entry.content as FeedCommentContent; if (!comment?.content) return null; diff --git a/components/Activity/slots/ActivityBountySlot.tsx b/components/Activity/slots/ActivityBountySlot.tsx new file mode 100644 index 000000000..cc9383095 --- /dev/null +++ b/components/Activity/slots/ActivityBountySlot.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { FC, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowRight, Info } from 'lucide-react'; +import { PrimaryActionSection } from '@/components/Feed/BaseFeedItem'; +import { Button } from '@/components/ui/Button'; +import { BountyDetailsModal } from '@/components/Bounty/BountyInfo'; +import { getBountyDisplayAmount } from '@/components/Bounty/lib/bountyUtil'; +import { formatCurrency } from '@/utils/currency'; +import { buildWorkUrl } from '@/utils/url'; +import { isDeadlineInFuture, getRemainingDays } from '@/utils/date'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import type { BountyType } from '@/types/bounty'; +import { cn } from '@/utils/styles'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { RadiatingDot } from '@/components/ui/RadiatingDot'; +import type { ContentFormat } from '@/types/comment'; +import type { CommentContent } from '@/components/Comment/lib/types'; +import type { ActivityWorkContext } from '../lib/activityWorkContext'; + +interface ActivityBountySlotProps { + work: ActivityWorkContext; + bountyDetails?: { + content: CommentContent; + format: ContentFormat; + }; +} + +export const ActivityBountySlot: FC = ({ work, bountyDetails }) => { + const router = useRouter(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const bounty = work.bounty; + if (!bounty) return null; + + const isActive = + bounty.status === 'OPEN' + ? bounty.expirationDate + ? isDeadlineInFuture(bounty.expirationDate) + : true + : bounty.status === 'ASSESSMENT'; + + const statusInfo = useMemo(() => { + if (bounty.status === 'OPEN' && isActive) { + const days = getRemainingDays(bounty.expirationDate ?? null); + const remaining = + days !== null + ? days < 1 + ? '< 1 day remaining' + : `${Math.floor(days)} day${Math.floor(days) === 1 ? '' : 's'} remaining` + : null; + return { label: 'Open', color: 'bg-green-500', remaining, urgent: days !== null && days < 3 }; + } + if (bounty.status === 'ASSESSMENT') { + return { label: 'Assessment', color: 'bg-orange-500', remaining: null, urgent: false }; + } + return { label: 'Completed', color: 'bg-gray-400', remaining: null, urgent: false }; + }, [bounty.status, bounty.expirationDate, isActive]); + + const displayAmount = getBountyDisplayAmount(bounty, exchangeRate, showUSD).amount; + const bountyLabel = bounty.bountyType === 'REVIEW' ? 'Peer Review' : 'Bounty'; + const buttonText = bounty.bountyType === 'REVIEW' ? 'Add Review' : 'Solve'; + const targetTab = bounty.bountyType === 'REVIEW' ? 'reviews' : 'bounties'; + + const handleDetailsClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDetailsModalOpen(true); + }; + + const handleAddSolutionClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const url = buildWorkUrl({ + id: work.id, + slug: work.slug, + contentType: work.documentType, + tab: targetTab, + }); + router.push(`${url}?focus=true`); + }; + + return ( + <> + +
+
+
+ {bountyLabel} + + {formatCurrency({ + amount: Math.round(displayAmount), + showUSD, + exchangeRate, + skipConversion: showUSD, + shorten: true, + })} + +
+ +
+ Status +
+ + {bounty.status === 'ASSESSMENT' ? ( + + + {statusInfo.label} + + + ) : ( + + {statusInfo.label} + + )} + {statusInfo.remaining && ( + + ({statusInfo.remaining}) + + )} +
+
+
+ +
+ + {isActive ? ( + + ) : ( + Ended + )} +
+
+
+ + setIsDetailsModalOpen(false)} + content={bountyDetails?.content ?? null} + contentFormat={bountyDetails?.format} + bountyType={bounty.bountyType as BountyType} + displayAmount={displayAmount} + showUSD={showUSD} + deadlineLabel={ + statusInfo.remaining ? `${statusInfo.label} (${statusInfo.remaining})` : statusInfo.label + } + onAddSolutionClick={handleAddSolutionClick} + buttonText={buttonText} + isActive={isActive} + /> + + ); +}; diff --git a/components/Activity/slots/ActivityCommentSlot.tsx b/components/Activity/slots/ActivityCommentSlot.tsx new file mode 100644 index 000000000..4af55ed2c --- /dev/null +++ b/components/Activity/slots/ActivityCommentSlot.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Quote } from 'lucide-react'; +import { CommentReadOnly } from '@/components/Comment/CommentReadOnly'; +import { CommentDetailsModal } from '@/components/modals/CommentDetailsModal'; +import { cn } from '@/utils/styles'; +import type { FeedCommentPreview } from '../lib/feedEntryAdapters'; + +const PREVIEW_MAX_HEIGHT_PX = 120; + +interface ActivityCommentSlotProps { + commentPreview: FeedCommentPreview; + isReview?: boolean; + reviewScore?: number; + workTitle?: string; + workHref?: string; + createdDate?: string; + updatedDate?: string; +} + +export const ActivityCommentSlot: FC = ({ + commentPreview, + isReview = false, + reviewScore, + workTitle, + workHref, + createdDate, + updatedDate, +}) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const previewRef = useRef(null); + + const measureOverflow = useCallback(() => { + const el = previewRef.current; + if (!el) return; + setIsOverflowing(el.scrollHeight > PREVIEW_MAX_HEIGHT_PX + 1); + }, []); + + useLayoutEffect(() => { + measureOverflow(); + const el = previewRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(measureOverflow); + observer.observe(el); + return () => observer.disconnect(); + }, [commentPreview.content, commentPreview.format, measureOverflow]); + + const openModal = () => { + if (!isOverflowing) return; + setIsModalOpen(true); + }; + + return ( + <> +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsModalOpen(true); + } + } + : undefined + } + role={isOverflowing ? 'button' : undefined} + tabIndex={isOverflowing ? 0 : undefined} + aria-label={ + isOverflowing ? (isReview ? 'View full review' : 'View full comment') : undefined + } + > + + + +
+
+ +
+ + {isOverflowing && ( +
+ )} +
+
+
+ + setIsModalOpen(false)} + content={commentPreview.content} + contentFormat={commentPreview.format} + isReview={isReview} + reviewScore={reviewScore} + workTitle={workTitle} + workHref={workHref} + createdDate={createdDate} + updatedDate={updatedDate} + /> + + ); +}; diff --git a/components/Activity/slots/ActivityFundraiseSlot.tsx b/components/Activity/slots/ActivityFundraiseSlot.tsx new file mode 100644 index 000000000..9f702813b --- /dev/null +++ b/components/Activity/slots/ActivityFundraiseSlot.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { FC, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowRight, Star } from 'lucide-react'; +import { PrimaryActionSection } from '@/components/Feed/BaseFeedItem'; +import { AvatarStack } from '@/components/ui/AvatarStack'; +import { Button } from '@/components/ui/Button'; +import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; +import { PeerReviewTooltip } from '@/components/tooltips/PeerReviewTooltip'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useShareModalContext } from '@/contexts/ShareContext'; +import { formatCurrency } from '@/utils/currency'; +import { isDeadlineInFuture } from '@/utils/date'; +import type { ActivityWorkContext } from '../lib/activityWorkContext'; + +interface ActivityFundraiseSlotProps { + work: ActivityWorkContext; + reviewScore?: number; +} + +export const ActivityFundraiseSlot: FC = ({ work, reviewScore }) => { + const router = useRouter(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const { showShareModal } = useShareModalContext(); + const [isContributeModalOpen, setIsContributeModalOpen] = useState(false); + + const fundraise = work.fundraise; + if (!fundraise) return null; + + const isActive = + fundraise.status === 'OPEN' && + (fundraise.endDate ? isDeadlineInFuture(fundraise.endDate) : true); + + const hasReviewScore = reviewScore !== undefined && reviewScore > 0; + + const contributors = useMemo( + () => + fundraise.contributors?.topContributors?.map((c) => ({ + src: c.authorProfile.profileImage || '', + alt: c.authorProfile.fullName, + tooltip: c.authorProfile.fullName, + authorId: c.authorProfile.id || undefined, + })) ?? [], + [fundraise.contributors?.topContributors] + ); + + const handleFundClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsContributeModalOpen(true); + }; + + const handleContributeSuccess = () => { + setIsContributeModalOpen(false); + showShareModal({ + url: window.location.href, + docTitle: work.title, + action: 'USER_FUNDED_PROPOSAL', + }); + router.refresh(); + }; + + return ( + <> + +
+
+
+ Requested + + {formatCurrency({ + amount: + fundraise.status === 'COMPLETED' + ? Math.round(showUSD ? fundraise.goalAmount.usd : fundraise.amountRaised.rsc) + : Math.round(showUSD ? fundraise.goalAmount.usd : fundraise.goalAmount.rsc), + showUSD, + exchangeRate, + skipConversion: true, + shorten: true, + })} + +
+ + {contributors.length > 0 && ( +
+ Backers + +
+ )} + + {hasReviewScore && ( +
+ Peer Review + + } + position="top" + width="w-[320px]" + > + + + {reviewScore!.toFixed(1)} + + +
+ )} +
+ + {isActive ? ( + + ) : ( + Ended + )} +
+
+ + setIsContributeModalOpen(false)} + onContributeSuccess={handleContributeSuccess} + fundraise={fundraise} + proposalTitle={work.title} + /> + + ); +}; diff --git a/components/Activity/slots/ActivityGrantSlot.tsx b/components/Activity/slots/ActivityGrantSlot.tsx new file mode 100644 index 000000000..37439d67d --- /dev/null +++ b/components/Activity/slots/ActivityGrantSlot.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { FC } from 'react'; +import Link from 'next/link'; +import { ArrowRight } from 'lucide-react'; +import { PrimaryActionSection } from '@/components/Feed/BaseFeedItem'; +import { Button } from '@/components/ui/Button'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { formatCurrency } from '@/utils/currency'; +import { isDeadlineInFuture } from '@/utils/date'; +import type { ActivityWorkContext } from '../lib/activityWorkContext'; + +interface ActivityGrantSlotProps { + work: ActivityWorkContext; +} + +export const ActivityGrantSlot: FC = ({ work }) => { + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const grant = work.grant; + + if (!grant) return null; + + 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; + + return ( + +
+
+ {hasBudget && ( +
+ + Available Funding + + + {formatCurrency({ + amount: Math.round(budgetAmount), + showUSD, + exchangeRate, + skipConversion: showUSD, + shorten: true, + })} + +
+ )} + +
+ Proposals + {grant.numApplicants} +
+ + {grant.organization && ( +
+ Offered by + + {grant.organization} + +
+ )} +
+ + {isActive && ( + + + + )} +
+
+ ); +}; diff --git a/components/Funding/ActivityCard.tsx b/components/Funding/ActivityCard.tsx index 10f2dadde..05569d475 100644 --- a/components/Funding/ActivityCard.tsx +++ b/components/Funding/ActivityCard.tsx @@ -5,11 +5,13 @@ import Link from 'next/link'; import { Star } from 'lucide-react'; import { Avatar } from '@/components/ui/Avatar'; import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import { ActivityHeaderActionText } from '@/components/Activity/ActivityHeaderActionText'; import { formatTimeAgo } from '@/utils/date'; +import { Tooltip } from '@/components/ui/Tooltip'; import type { FeedEntry } from '@/types/feed'; import { getActionIcon, - getActionLabel, + getActivityHeaderMessage, getContribution, getEntryMeta, getGrantAmount, @@ -24,11 +26,11 @@ interface ActivityCardProps { } 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 grantAmount = getGrantAmount(entry); @@ -45,22 +47,19 @@ export const ActivityCard: FC = ({ entry }) => { return (
-
- +
+
- - {author?.fullName || 'Unknown'} - - {actionLabel} + {reviewScore != null && ( @@ -75,9 +74,11 @@ export const ActivityCard: FC = ({ entry }) => { {titleEl}
- - {formatTimeAgo(entry.timestamp)} - + + + {formatTimeAgo(entry.timestamp)} + +
); }; diff --git a/components/Funding/GrantContentSwitcher.tsx b/components/Funding/GrantContentSwitcher.tsx index 706f1dd22..5fabe2de9 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; @@ -41,27 +34,13 @@ export function GrantContentSwitcher({
-
+
{entries.map((entry) => ( ))} - {(isLoading || isLoadingMore) && ( -
- {[...Array(8)].map((_, i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- )} + {(isLoading || isLoadingMore) && + [...Array(8)].map((_, i) => )} {!isLoading && !isLoadingMore && entries.length === 0 && (
diff --git a/components/modals/CommentDetailsModal.tsx b/components/modals/CommentDetailsModal.tsx new file mode 100644 index 000000000..c132bfd78 --- /dev/null +++ b/components/modals/CommentDetailsModal.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { FC } from 'react'; +import Link from 'next/link'; +import { MessageSquare, Star } from 'lucide-react'; +import { BaseModal } from '@/components/ui/BaseModal'; +import { CommentReadOnly } from '@/components/Comment/CommentReadOnly'; +import { ReviewScoreStars } from '@/components/Activity/ReviewScoreStars'; +import type { ContentFormat } from '@/types/comment'; +import type { CommentContent } from '@/components/Comment/lib/types'; + +interface CommentDetailsModalProps { + isOpen: boolean; + onClose: () => void; + content: CommentContent; + contentFormat?: ContentFormat; + isReview?: boolean; + reviewScore?: number; + workTitle?: string; + workHref?: string; + createdDate?: string; + updatedDate?: string; +} + +export const CommentDetailsModal: FC = ({ + isOpen, + onClose, + content, + contentFormat, + isReview = false, + reviewScore, + workTitle, + workHref, + createdDate, + updatedDate, +}) => ( + + {isReview ? ( +
+ + Peer Review +
+ ) : ( +
+ + Comment +
+ )} + {workTitle && workHref && ( + + {workTitle} + + )} +
+ } + > + {isReview && reviewScore != null && reviewScore > 0 && ( +
+ + Overall Score + +
+ + {reviewScore.toFixed(1)} +
+
+ )} + + +); diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index e88dd0f55..5d85d2a9f 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -80,6 +80,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' }, @@ -97,7 +99,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/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 92255e533..96c90a925 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -2,7 +2,15 @@ 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'; @@ -13,6 +21,7 @@ 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 +37,52 @@ export interface ParentCommentPreview { parentComment?: ParentCommentPreview | undefined; // Add recursive field } +function parseFundingActivityCommentContent(rawContent: unknown): unknown { + if (typeof rawContent !== 'string') return rawContent; + try { + return JSON.parse(rawContent); + } catch { + return rawContent; + } +} + +function transformFundingActivityTippedComment( + commentObj: Record +): FeedFundingActivityContent['tippedComment'] { + const review = commentObj.review as { score?: number; is_assessed?: boolean } | null | undefined; + const authorRaw = commentObj.author as Record | undefined; + return { + id: commentObj.id as number, + content: parseFundingActivityCommentContent(commentObj.comment_content_json), + contentFormat: (commentObj.comment_content_type as ContentFormat) || 'QUILL_EDITOR', + commentType: commentObj.comment_type as CommentType, + score: (commentObj.score as number) || 0, + reviewScore: review?.score || 0, + isAssessed: review?.is_assessed ?? false, + ...(authorRaw ? { createdBy: transformAuthorProfile(authorRaw) } : {}), + ...(review ? { review: { score: review.score || 0 } } : {}), + }; +} + +function transformFundingActivityRecipient(recipients: unknown): AuthorProfile | undefined { + const first = Array.isArray(recipients) ? recipients[0] : undefined; + if (!first || typeof first !== 'object') return undefined; + + const recipient = first as { + recipient_user?: Record; + author_profile?: Record; + author?: Record; + }; + const profileRaw = recipient.recipient_user ?? recipient.author_profile ?? recipient.author; + if (!profileRaw || typeof profileRaw !== 'object') return undefined; + + try { + return transformAuthorProfile(profileRaw); + } catch { + return undefined; + } +} + // Recursive helper function to transform nested parent comments const transformNestedParentComment = (rawParent: any): ParentCommentPreview | undefined => { if (!rawParent) { @@ -141,7 +196,6 @@ export interface FeedCommentContent extends BaseFeedContent { objectId: number; }; }; - hasBounties?: boolean; isRemoved?: boolean; relatedDocumentId?: number | string; relatedDocumentContentType?: ContentType; @@ -205,6 +259,21 @@ 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; + recipient?: AuthorProfile; + tippedComment?: FeedCommentContent['comment'] & { + review?: { score: number }; + createdBy?: AuthorProfile; + }; +} + // Update the Content union type to include the base interface export type Content = | FeedPostContent @@ -212,7 +281,8 @@ export type Content = | FeedBountyContent | FeedCommentContent | FeedApplicationContent - | FeedGrantContent; + | FeedGrantContent + | FeedFundingActivityContent; export type FeedContentType = | 'PAPER' @@ -223,7 +293,8 @@ export type FeedContentType = | 'APPLICATION' | 'GRANT' | 'USDFUNDRAISECONTRIBUTION' - | 'PURCHASE'; + | 'PURCHASE' + | 'FUNDINGACTIVITY'; export interface ExternalMetrics { score: number; @@ -280,6 +351,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 FeedEntry { id: string; recommendationId: string | null; @@ -300,6 +382,7 @@ export interface FeedEntry { externalMetrics?: ExternalMetrics; nonprofit?: Nonprofit; associatedGrants?: AssociatedGrant[]; + activityContext?: ActivityContext; searchMetadata?: { highlightedTitle?: string; highlightedSnippet?: string; @@ -332,6 +415,7 @@ export interface RawApiFeedEntry { base_wallet_address: string; }; hot_score_v2?: number; + related_work?: any; associated_grants?: Array<{ id: number; post_id: number; @@ -429,6 +513,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. * @@ -458,6 +548,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) { @@ -730,8 +892,19 @@ 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({ + created_by: author || content_object.author || null, + ...bounty, + }) + ) + : 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 = { @@ -742,12 +915,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, @@ -949,14 +1122,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 ? [ @@ -982,6 +1157,45 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { } break; + case 'FUNDINGACTIVITY': + contentType = 'FUNDINGACTIVITY'; + try { + const relatedWorkRaw = feedEntry.related_work; + + let tippedComment: FeedFundingActivityContent['tippedComment']; + if (content_object.comment) { + tippedComment = transformFundingActivityTippedComment(content_object.comment); + } + + const totalAmountRaw = content_object.total_amount; + const totalAmount = + typeof totalAmountRaw === 'string' + ? parseFloat(totalAmountRaw) || 0 + : totalAmountRaw || 0; + + 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: 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), + recipient: transformFundingActivityRecipient(content_object.recipients), + tippedComment, + }; + 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( @@ -1024,6 +1238,11 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { } // Complete the feed entry + const activityRelatedWork = transformActivityRelatedWork(feedEntry.related_work); + if (activityRelatedWork) { + relatedWork = activityRelatedWork; + } + return { ...baseFeedEntry, content, @@ -1087,6 +1306,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 e08266bef..e532b0a6c 100644 --- a/types/work.ts +++ b/types/work.ts @@ -128,6 +128,7 @@ export interface Work { aiPeerReview?: ProposalReview | null; enrichments?: Enrichment[]; linkedGrant?: LinkedGrant | null; + grantSummary?: WorkGrantSummary; isPublic?: boolean; } @@ -144,6 +145,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'; From 71253d809d91b722b887f280869e7cd64fb9360d Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Mon, 15 Jun 2026 23:24:34 +0300 Subject: [PATCH 2/4] small fixes --- components/Activity/ActivityWorkPanel.tsx | 18 ++++-------- components/Activity/FeedEntryIcon.tsx | 30 ++++++++++++++++---- components/Activity/lib/feedEntryAdapters.ts | 8 +++++- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/components/Activity/ActivityWorkPanel.tsx b/components/Activity/ActivityWorkPanel.tsx index b898bc3cf..ad7dfa77c 100644 --- a/components/Activity/ActivityWorkPanel.tsx +++ b/components/Activity/ActivityWorkPanel.tsx @@ -2,7 +2,6 @@ import { FC, ReactNode } from 'react'; import Link from 'next/link'; -import { FileText } from 'lucide-react'; import { ImageSection } from '@/components/Feed/ImageSection'; import { AuthorList } from '@/components/ui/AuthorList'; import type { ActivityWorkContext } from './lib/activityWorkContext'; @@ -32,9 +31,9 @@ export const ActivityWorkPanel: FC = ({ work, children,
)} -
-
- {imageUrl ? ( +
+ {imageUrl && ( +
= ({ work, children, previewOnClick={false} />
- ) : ( -
- -
- )} -
+
+ )}
, LucideIcon> = { - coins: Coins, +const ICONS: Record, LucideIcon> = { bell: Bell, message: MessageCircle, }; @@ -13,7 +17,23 @@ 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 ( + + ); + } + const IconComponent = ICONS[name]; + return ; }; diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index c3c6ea7fa..f72b96799 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -218,9 +218,15 @@ export function getEntryMeta(entry: FeedEntry): FeedEntryMeta { }; } -export type FeedEntryIconName = 'coins' | 'bell' | 'message' | null; +export type FeedEntryIconName = 'coins' | 'fund' | 'earn' | 'bell' | 'message' | null; export function getActionIcon(entry: FeedEntry): FeedEntryIconName { + if (entry.contentType === 'GRANT' || entry.activityContext === 'grant_opened') { + return 'fund'; + } + if (entry.activityContext === 'bounty_opened') { + return 'earn'; + } if ( entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE' || From 2c22c9545c903e3535fd91d8e5011495e6dd4173 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Mon, 15 Jun 2026 23:31:25 +0300 Subject: [PATCH 3/4] minor --- components/Activity/FeedEntryIcon.tsx | 17 +++++++++++++++-- components/Activity/lib/feedEntryAdapters.ts | 5 ++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/components/Activity/FeedEntryIcon.tsx b/components/Activity/FeedEntryIcon.tsx index 93190e37f..b984e4493 100644 --- a/components/Activity/FeedEntryIcon.tsx +++ b/components/Activity/FeedEntryIcon.tsx @@ -3,11 +3,14 @@ import { FC } from 'react'; import { Bell, MessageCircle, type LucideIcon } from 'lucide-react'; import Icon from '@/components/ui/icons/Icon'; -import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; +import { ResearchCoinIcon, RSC_COLORS } from '@/components/ui/icons/ResearchCoinIcon'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import type { FeedEntryIconName } from './lib/feedEntryAdapters'; -const ICONS: Record, LucideIcon> = { +const ICONS: Record< + Exclude, + LucideIcon +> = { bell: Bell, message: MessageCircle, }; @@ -34,6 +37,16 @@ export const FeedEntryIcon: FC = ({ name }) => { ); } + if (name === 'proposal') { + return ( + + ); + } const IconComponent = ICONS[name]; return ; }; diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index f72b96799..542950c29 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -218,7 +218,7 @@ export function getEntryMeta(entry: FeedEntry): FeedEntryMeta { }; } -export type FeedEntryIconName = 'coins' | 'fund' | 'earn' | 'bell' | 'message' | null; +export type FeedEntryIconName = 'coins' | 'fund' | 'earn' | 'proposal' | 'bell' | 'message' | null; export function getActionIcon(entry: FeedEntry): FeedEntryIconName { if (entry.contentType === 'GRANT' || entry.activityContext === 'grant_opened') { @@ -227,6 +227,9 @@ export function getActionIcon(entry: FeedEntry): FeedEntryIconName { if (entry.activityContext === 'bounty_opened') { return 'earn'; } + if (entry.activityContext === 'proposal_submitted' || entry.contentType === 'PREREGISTRATION') { + return 'proposal'; + } if ( entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE' || From 774c590859f7eb27d69370fcdae1814fac3d9594 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Tue, 16 Jun 2026 20:30:10 +0300 Subject: [PATCH 4/4] fix issues --- components/Activity/lib/feedEntryAdapters.ts | 33 ++-------- types/feed.ts | 69 +++++--------------- 2 files changed, 25 insertions(+), 77 deletions(-) diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index 542950c29..9b945c295 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -35,10 +35,6 @@ const FEED_TO_CONTENT_TYPE: Partial> = { PAPER: 'paper', }; -function isPeerReviewTipComment(commentType?: CommentType | string): boolean { - return commentType === 'PEER_REVIEW' || commentType === 'REVIEW'; -} - export interface ActivityHeaderTarget { author: AuthorProfile; suffix?: string; @@ -62,20 +58,19 @@ function getFundingActivityMessage(content: FeedFundingActivityContent): Activit }; } - const tippedAuthor = content.tippedComment?.createdBy; - const isPeerReviewTip = isPeerReviewTipComment(content.tippedComment?.commentType); - if (!tippedAuthor) { + const recipient = content.recipient; + if (!recipient) { return { actor, - verb: isPeerReviewTip ? 'tipped review' : 'tipped comment', + verb: 'tipped review', }; } return { actor, verb: 'tipped', target: { - author: tippedAuthor, - suffix: isPeerReviewTip ? "'s review" : "'s comment", + author: recipient, + suffix: "'s review", }, }; } @@ -250,12 +245,7 @@ export function getActionIcon(entry: FeedEntry): FeedEntryIconName { } export function getReviewScore(entry: FeedEntry): number | undefined { - if (entry.contentType === 'FUNDINGACTIVITY') { - const funding = entry.content as FeedFundingActivityContent; - const tippedComment = funding.tippedComment; - if (!isPeerReviewTipComment(tippedComment?.commentType)) return undefined; - return tippedComment?.review?.score ?? tippedComment?.reviewScore; - } + 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; @@ -326,16 +316,7 @@ export interface FeedCommentPreview { } export function getCommentPreview(entry: FeedEntry): FeedCommentPreview | null { - if (entry.contentType === 'FUNDINGACTIVITY') { - const funding = entry.content as FeedFundingActivityContent; - const tippedComment = funding.tippedComment; - if (!tippedComment?.content) return null; - return { - content: tippedComment.content, - format: tippedComment.contentFormat, - isReview: isPeerReviewTipComment(tippedComment.commentType), - }; - } + 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/types/feed.ts b/types/feed.ts index c36bc358d..12f859688 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -16,7 +16,6 @@ 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'; @@ -37,47 +36,24 @@ export interface ParentCommentPreview { parentComment?: ParentCommentPreview | undefined; // Add recursive field } -function parseFundingActivityCommentContent(rawContent: unknown): unknown { - if (typeof rawContent !== 'string') return rawContent; - try { - return JSON.parse(rawContent); - } catch { - return rawContent; - } -} - -function transformFundingActivityTippedComment( - commentObj: Record -): FeedFundingActivityContent['tippedComment'] { - const review = commentObj.review as { score?: number; is_assessed?: boolean } | null | undefined; - const authorRaw = commentObj.author as Record | undefined; - return { - id: commentObj.id as number, - content: parseFundingActivityCommentContent(commentObj.comment_content_json), - contentFormat: (commentObj.comment_content_type as ContentFormat) || 'QUILL_EDITOR', - commentType: commentObj.comment_type as CommentType, - score: (commentObj.score as number) || 0, - reviewScore: review?.score || 0, - isAssessed: review?.is_assessed ?? false, - ...(authorRaw ? { createdBy: transformAuthorProfile(authorRaw) } : {}), - ...(review ? { review: { score: review.score || 0 } } : {}), - }; -} - function transformFundingActivityRecipient(recipients: unknown): AuthorProfile | undefined { const first = Array.isArray(recipients) ? recipients[0] : undefined; if (!first || typeof first !== 'object') return undefined; - const recipient = first as { - recipient_user?: Record; - author_profile?: Record; - author?: Record; - }; - const profileRaw = recipient.recipient_user ?? recipient.author_profile ?? recipient.author; - if (!profileRaw || typeof profileRaw !== 'object') return undefined; + const recipientUser = (first as { recipient_user?: Record }).recipient_user; + if (!recipientUser || typeof recipientUser !== 'object') return undefined; try { - return transformAuthorProfile(profileRaw); + 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; } @@ -267,11 +243,8 @@ export interface FeedFundingActivityContent extends BaseFeedContent { totalAmount: number; totalUsdCents: number; totalUsd: number; + activityDate?: string; recipient?: AuthorProfile; - tippedComment?: FeedCommentContent['comment'] & { - review?: { score: number }; - createdBy?: AuthorProfile; - }; } // Update the Content union type to include the base interface @@ -897,10 +870,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { const bounties = Array.isArray(content_object.bounties) ? content_object.bounties.map((bounty: Record) => - transformBounty({ - created_by: author || content_object.author || null, - ...bounty, - }) + transformBounty(bounty, { ignoreBaseAmount: true }) ) : undefined; @@ -1165,32 +1135,29 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { try { const relatedWorkRaw = feedEntry.related_work; - let tippedComment: FeedFundingActivityContent['tippedComment']; - if (content_object.comment) { - tippedComment = transformFundingActivityTippedComment(content_object.comment); - } - 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: transformAuthorProfile(author), + 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), - tippedComment, }; content = fundingActivityContent; } catch (error) {