+
{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 d6affb928..12f859688 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 FeedEntry {
id: string;
recommendationId: string | null;
@@ -302,6 +357,7 @@ export interface FeedEntry {
externalMetrics?: ExternalMetrics;
nonprofit?: Nonprofit;
associatedGrants?: AssociatedGrant[];
+ activityContext?: ActivityContext;
searchMetadata?: {
highlightedTitle?: string;
highlightedSnippet?: string;
@@ -334,6 +390,7 @@ export interface RawApiFeedEntry {
base_wallet_address: string;
};
hot_score_v2?: number;
+ related_work?: any;
risk_score?: number | null;
associated_grants?: Array<{
id: number;
@@ -432,6 +489,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.
*
@@ -461,6 +524,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) {
@@ -733,8 +868,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 = {
@@ -745,12 +888,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,
@@ -952,14 +1095,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
? [
@@ -985,6 +1130,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(
@@ -1027,6 +1208,11 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => {
}
// Complete the feed entry
+ const activityRelatedWork = transformActivityRelatedWork(feedEntry.related_work);
+ if (activityRelatedWork) {
+ relatedWork = activityRelatedWork;
+ }
+
return {
...baseFeedEntry,
content,
@@ -1091,6 +1277,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 082cff494..8fe10527d 100644
--- a/types/work.ts
+++ b/types/work.ts
@@ -130,6 +130,7 @@ export interface Work {
aiPeerReview?: ProposalReview | null;
enrichments?: Enrichment[];
linkedGrant?: LinkedGrant | null;
+ grantSummary?: WorkGrantSummary;
moderationStatus?: ModerationStatus;
isPublic?: boolean;
}
@@ -147,6 +148,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';