Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { HeroHeader } from '@/components/ui/HeroHeader';
import { PillTabs } from '@/components/ui/PillTabs';
import { ActivityCardFull } from '@/components/Activity/ActivityCardFull';
import { ActivityCardSkeletonList } from '@/components/Activity/ActivityCardSkeleton';
import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton';
import { useActivityFeed, ActivityTab } from '@/hooks/useActivityFeed';
import { ActivityScope } from '@/services/activity.service';
import { GrantService } from '@/services/grant.service';
Expand Down Expand Up @@ -106,7 +106,8 @@
<ActivityCardFull key={entry.id} entry={entry} />
))}

{(isLoading || isLoadingMore) && <ActivityCardSkeletonList />}
{(isLoading || isLoadingMore) &&
[...Array(6)].map((_, i) => <ActivityCardSkeleton key={i} />)}

Check warning on line 110 in app/activity/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ-9jO5gu058H2PmplQZ&open=AZ-9jO5gu058H2PmplQZ&pullRequest=981

Check warning on line 110 in app/activity/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `new Array()` instead of `Array()`.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ-9jO5gu058H2PmplQY&open=AZ-9jO5gu058H2PmplQY&pullRequest=981

{!isLoading && !isLoadingMore && entries.length === 0 && (
<div className="py-12 text-center">
Expand Down
8 changes: 1 addition & 7 deletions app/grant/[id]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,9 @@ export default async function GrantSlugPage({ params }: Props) {
const work = await getGrant(id);

const grant = work.note?.post?.grant;
const grantId = grant?.id ?? undefined;

return (
<GrantContentSwitcher
content={work.previewContent}
imageUrl={work.image}
hasDescription={!!grant?.description}
grantId={grantId}
>
<GrantContentSwitcher content={work.previewContent} imageUrl={work.image}>
{grant?.description && <ProposalSortAndFilters />}
<ProposalFeed />
</GrantContentSwitcher>
Expand Down
203 changes: 118 additions & 85 deletions components/Activity/ActivityCardFull.tsx
Original file line number Diff line number Diff line change
@@ -1,121 +1,154 @@
'use client';

import { FC, useState } from 'react';
import Link from 'next/link';
import { Star, ChevronDown, ChevronUp } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
import { Button } from '@/components/ui/Button';
import { CommentReadOnly } from '@/components/Comment/CommentReadOnly';
import { ContributionAmount } from './ContributionAmount';
import { FeedEntryIcon } from './FeedEntryIcon';
import { GrantFundingAmount } from './GrantFundingAmount';
import {
getActionIcon,
getActionLabel,
getCommentPreview,
getContribution,
getEntryMeta,
getGrantAmount,
getReviewScore,
} from './lib/feedEntryAdapters';
import { formatTimeAgo } from '@/utils/date';
import { FeedItemActions } from '@/components/Feed/FeedItemActions';
import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { useExchangeRate } from '@/contexts/ExchangeRateContext';
import { useShareModalContext } from '@/contexts/ShareContext';
import { ActivityCardHeader } from './ActivityCardHeader';
import { WorkPreviewCard } from './WorkPreviewCard';
import { getActivityHeaderMessage, getCommentPreview } from './lib/feedEntryAdapters';
import { getActivityWorkContext, getWorkCardPresentation } from './lib/activityWorkContext';
import type { FeedEntry } from '@/types/feed';

interface ActivityCardFullProps {
entry: FeedEntry;
}

export const ActivityCardFull: FC<ActivityCardFullProps> = ({ entry }) => {
const { title, author, href } = getEntryMeta(entry);
const [reviewExpanded, setReviewExpanded] = useState(false);
const work = getActivityWorkContext(entry);
const router = useRouter();
const { showUSD } = useCurrencyPreference();
const { exchangeRate } = useExchangeRate();
const { showShareModal } = useShareModalContext();
const [isContributeModalOpen, setIsContributeModalOpen] = useState(false);

if (!title) return null;
if (!work) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render self-contained activity entries

When the activity payload is a top-level document event (for example PAPER or RESEARCHHUBPOST for a published paper/grant/proposal) and does not include related_work, this early return drops the row entirely even though transformFeedEntry still builds the document from content_object. The previous activity card fell back to the entry content via getEntryMeta, so these entries remained visible; please add a fallback work context from the entry content instead of requiring entry.relatedWork.

Useful? React with 👍 / 👎.


const actionLabel = getActionLabel(entry);
const actionIcon = getActionIcon(entry);
const reviewScore = getReviewScore(entry);
const grantAmount = getGrantAmount(entry);
const contribution = getContribution(entry);
const message = getActivityHeaderMessage(entry);
const commentPreview = getCommentPreview(entry);
const presentation = getWorkCardPresentation(entry, work, {
showUSD,
exchangeRate,
isReview: commentPreview?.isReview,
});

const titleEl = href ? (
<Link href={href} className="text-primary-600 hover:text-primary-800">
{title}
</Link>
) : (
<span className="text-gray-500">{title}</span>
);
const showComment = presentation.showComment && !!commentPreview;
const voteCount = entry.metrics?.adjustedScore ?? entry.metrics?.votes ?? 0;
const isReviewOfProposal = !!commentPreview?.isReview && work.documentType === 'preregistration';
const feedContentType = work.documentType === 'paper' ? 'PAPER' : 'POST';

const handleFundClick = () => {
setIsContributeModalOpen(true);
};

const handleContributeSuccess = () => {
setIsContributeModalOpen(false);
showShareModal({
url: window.location.href,
docTitle: work.title,
action: 'USER_FUNDED_PROPOSAL',
});
router.refresh();
};

const action = (() => {
const cta = presentation.cta;
if (!cta) return undefined;

return (
<Button
variant="dark"
size="sm"
onClick={cta.kind === 'fund-modal' ? handleFundClick : () => router.push(cta.href)}
className="rounded-md gap-1"
>
{cta.label}
<ArrowRight size={14} aria-hidden />
</Button>
);
})();

return (
<div className="py-4 border-b border-gray-100 last:border-b-0">
<div className="grid grid-cols-[auto_1fr] gap-x-2.5 items-start">
<div className="row-span-2 pt-0.5">
<AuthorTooltip authorId={author?.id} placement="bottom">
<article className="py-4 border-b border-gray-100 last:border-b-0">
<div className="flex gap-2.5">
<div className="flex w-8 flex-shrink-0 flex-col items-center">
<div className="pt-0.5">
<Avatar
src={author?.profileImage}
alt={author?.fullName || 'User'}
src={message.actor.profileImage}
alt={message.actor.fullName || 'User'}
size={32}
authorId={author?.id}
disableTooltip
authorId={message.actor.id}
/>
</AuthorTooltip>
</div>
<div className="flex flex-wrap items-center gap-x-1.5 text-sm leading-tight mb-1">
<span className="font-medium text-gray-900">{author?.fullName || 'Unknown'}</span>
<span className="text-gray-500">{actionLabel}</span>
<FeedEntryIcon name={actionIcon} />
{reviewScore != null && (
<span className="inline-flex items-center gap-1 text-xs text-gray-600 align-middle">
<Star size={13} className="fill-amber-400 text-amber-400" />
{reviewScore.toFixed(1)}
</span>
)}
{grantAmount && <GrantFundingAmount amount={grantAmount} />}
{contribution && (
<ContributionAmount contribution={contribution} className="text-gray-900" />
)}
</div>
</div>
<span className="text-sm leading-tight">{titleEl}</span>
</div>

{commentPreview && !commentPreview.isReview && (
<div className="mt-2 ml-[42px]">
<CommentReadOnly
content={commentPreview.content}
contentFormat={commentPreview.format}
maxLength={250}
showReadMoreButton={true}
className="text-sm"
/>
</div>
)}
<div className="min-w-0 flex-1">
<ActivityCardHeader entry={entry} />

{commentPreview && commentPreview.isReview && (
<div className="mt-2 ml-[42px]">
<button
className="text-sm text-blue-600 hover:text-blue-800 hover:underline cursor-pointer inline-flex items-center gap-0.5"
onClick={() => setReviewExpanded((open) => !open)}
>
{reviewExpanded ? 'Hide review' : 'Read review'}
{reviewExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{reviewExpanded && (
{showComment && commentPreview && (
<div className="mt-2">
<CommentReadOnly
content={commentPreview.content}
contentFormat={commentPreview.format}
initiallyExpanded={true}
showReadMoreButton={false}
maxLength={250}
showReadMoreButton
showLinkPreviews={false}
className="text-sm"
/>
</div>
)}

<div className="mt-5 -ml-[42px] tablet:!ml-0">
<WorkPreviewCard
title={work.title}
href={work.href}
imageSrc={work.imageUrl}
showPlaceholder
authors={isReviewOfProposal ? [] : presentation.authors}
organization={presentation.organization}
institution={presentation.institution}
score={presentation.score}
stats={presentation.stats}
progress={presentation.progress}
actions={
<FeedItemActions
metrics={{ votes: voteCount, adjustedScore: voteCount }}
feedContentType={feedContentType}
votableEntityId={work.id}
relatedDocumentId={work.id.toString()}
relatedDocumentContentType={work.documentType}
relatedDocumentUnifiedDocumentId={work.unifiedDocumentId?.toString()}
userVote={entry.userVote}
href={work.href}
hideCommentButton
hideReportButton
variant="inline"
leadingUtilityActions
rightSideActionButton={action}
className="gap-1"
/>
}
/>
</div>
</div>
)}
</div>

<span className="block text-xs text-gray-400 mt-1 ml-[42px]">
{formatTimeAgo(entry.timestamp)}
</span>
</div>
{work.fundraise && (
<ContributeToFundraiseModal
isOpen={isContributeModalOpen}
onClose={() => setIsContributeModalOpen(false)}
onContributeSuccess={handleContributeSuccess}
fundraise={work.fundraise}
proposalTitle={work.title}
/>
)}
</article>
);
};
95 changes: 95 additions & 0 deletions components/Activity/ActivityCardHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client';

import { FC } from 'react';
import { ActivityHeaderActionText } from './ActivityHeaderActionText';
import { BountyAmount } from './BountyAmount';
import { ContributionAmount } from './ContributionAmount';
import { FeedEntryIcon } from './FeedEntryIcon';
import { GrantFundingAmount } from './GrantFundingAmount';
import { ReviewScoreStars } from './ReviewScoreStars';
import {
getActionIcon,
getActivityHeaderMessage,
getContribution,
getGrantAmount,
getReviewEarning,
getReviewScore,
} from './lib/feedEntryAdapters';
import { getActivityBounty } from './lib/activityWorkContext';
import { formatTimeAgo } from '@/utils/date';
import { Tooltip } from '@/components/ui/Tooltip';
import type { FeedEntry } from '@/types/feed';

interface ActivityCardHeaderProps {
entry: FeedEntry;
}

export const ActivityCardHeader: FC<ActivityCardHeaderProps> = ({ entry }) => {
const message = getActivityHeaderMessage(entry);
const actionIcon = getActionIcon(entry);
const reviewScore = getReviewScore(entry);
const reviewEarning = getReviewEarning(entry);
const grantAmount = getGrantAmount(entry);
const contribution = getContribution(entry);
const bounty = entry.activityContext === 'bounty_opened' ? getActivityBounty(entry) : undefined;

const hasAmount = Boolean(
grantAmount || contribution || reviewEarning || bounty || reviewScore != null
);

return (
<div className="mb-2.5 flex items-start justify-between gap-2 pt-1 text-sm">
<div className="min-w-0 flex-1 leading-6">
<ActivityHeaderActionText message={message} />
{grantAmount && (
<>
{' '}
<GrantFundingAmount amount={grantAmount} className="align-middle" />
</>
)}
{contribution && (
<>
{' '}
<ContributionAmount
contribution={contribution}
showSign={!message.isEarning}
className="align-middle"
/>
</>
)}
{reviewEarning && (
<>
{' '}
<ContributionAmount
contribution={reviewEarning}
showSign={false}
className="align-middle"
/>
</>
)}
{bounty && (
<>
{' '}
<BountyAmount bounty={bounty} className="align-middle" />
</>
)}
{reviewScore != null && reviewScore > 0 && (
<>
{' '}
<ReviewScoreStars score={reviewScore} size="sm" className="align-middle" />
</>
)}
<FeedEntryIcon name={hasAmount ? null : actionIcon} />
</div>

<Tooltip
content={new Date(entry.timestamp).toLocaleString()}
wrapperClassName="flex-shrink-0"
>
<span className="text-xs leading-6 text-gray-400 cursor-default whitespace-nowrap">
{formatTimeAgo(entry.timestamp)}
</span>
</Tooltip>
</div>
);
};
Loading