-
-
-
-
+
- {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/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/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/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..cacfb22ea 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,14 @@ export interface LinkedGrant {
applicantCount: number;
}
+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/number.ts b/utils/number.ts
index 36ba7d32d..fe4e24dad 100644
--- a/utils/number.ts
+++ b/utils/number.ts
@@ -80,6 +80,22 @@ export function formatBadgeCount(count: number): string {
return count > 9 ? '9+' : count.toString();
}
+/**
+ * Coerce a string/number id-like value to a finite number, or undefined.
+ *
+ * @example
+ * toOptionalNumber('42') // 42
+ * toOptionalNumber(7) // 7
+ * toOptionalNumber('') // undefined
+ * toOptionalNumber(null) // undefined
+ * toOptionalNumber('abc') // undefined
+ */
+export function toOptionalNumber(value: string | number | null | undefined): number | undefined {
+ if (value == null || value === '') return undefined;
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
/**
* Returns the number of decimal places in a number
* @example