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
21 changes: 4 additions & 17 deletions app/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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';
Expand Down Expand Up @@ -100,27 +101,13 @@
<div className="max-w-3xl mx-auto">
{tabsElement}

<div className="mt-4">
<div className="mt-4 divide-y divide-gray-200">
{entries.map((entry) => (
<ActivityCardFull key={entry.id} entry={entry} />
))}

{(isLoading || isLoadingMore) && (
<div className="py-8 space-y-6">
{[...Array(15)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="flex gap-2.5">
<div className="w-8 h-8 bg-gray-200 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-3 bg-gray-200 rounded w-2/3" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
</div>
</div>
))}
</div>
)}
{(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=AZ7MwV9dUM3U-UaN9XDy&open=AZ7MwV9dUM3U-UaN9XDy&pullRequest=905

{!isLoading && !isLoadingMore && entries.length === 0 && (
<div className="py-12 text-center">
Expand Down
7 changes: 1 addition & 6 deletions app/grant/[id]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,7 @@ export default async function GrantSlugPage({ params }: Props) {
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}>
<FundraiseProvider grantId={grantId ? Number(grantId) : undefined}>
{grant?.description && <ProposalSortAndFilters />}
<ProposalFeed />
Expand Down
193 changes: 91 additions & 102 deletions components/Activity/ActivityCardFull.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof getActivityWorkContext>;
reviewScore?: number;
commentPreview: ReturnType<typeof getCommentPreview>;
}) {

Check warning on line 33 in components/Activity/ActivityCardFull.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ7MwV8tUM3U-UaN9XDs&open=AZ7MwV8tUM3U-UaN9XDs&pullRequest=905
if (!work) return null;

switch (slot) {
case 'fundraise':
return <ActivityFundraiseSlot work={work} reviewScore={reviewScore} />;
case 'bounty':
return (
<ActivityBountySlot
work={work}
bountyDetails={
commentPreview
? { content: commentPreview.content, format: commentPreview.format }
: undefined
}
/>
);
case 'grant':
return <ActivityGrantSlot work={work} />;
default:
return null;
}
}

export const ActivityCardFull: FC<ActivityCardFullProps> = ({ 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 ? (
<Link href={href} className="text-primary-600 hover:text-primary-800">
{title}
</Link>
) : (
<span className="text-gray-500">{title}</span>
);
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;

Check warning on line 76 in components/Activity/ActivityCardFull.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ7MwV8tUM3U-UaN9XDu&open=AZ7MwV8tUM3U-UaN9XDu&pullRequest=905

Check warning on line 76 in components/Activity/ActivityCardFull.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ7MwV8tUM3U-UaN9XDt&open=AZ7MwV8tUM3U-UaN9XDt&pullRequest=905

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">
<Avatar
src={author?.profileImage}
alt={author?.fullName || 'User'}
size={32}
authorId={author?.id}
disableTooltip
/>
</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>
<span className="text-sm leading-tight">{titleEl}</span>
</div>
const commentEntry =
entry.contentType === 'COMMENT' ? (entry.content as FeedCommentContent) : undefined;

{commentPreview && !commentPreview.isReview && (
<div className="mt-2 ml-[42px]">
<CommentReadOnly
content={commentPreview.content}
contentFormat={commentPreview.format}
maxLength={250}
showReadMoreButton={true}
className="text-sm"
/>
</div>
)}
const showCommentSlot = shouldShowActivityComment(effectiveSlot, commentPreview);

{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 && (
<div className="mt-2">
<CommentReadOnly
content={commentPreview.content}
contentFormat={commentPreview.format}
initiallyExpanded={true}
showReadMoreButton={false}
className="text-sm"
/>
</div>
)}
</div>
)}
const fundraiseSlotReviewScore =
effectiveSlot === 'fundraise' && showCommentSlot ? undefined : reviewScore;

<span className="block text-xs text-gray-400 mt-1 ml-[42px]">
{formatTimeAgo(entry.timestamp)}
</span>
</div>
return (
<article className="py-5">
<ActivityCardHeader entry={entry} />
<ActivityWorkPanel work={work}>
<ActivityBodySlot
slot={effectiveSlot}
work={work}
reviewScore={fundraiseSlotReviewScore}
commentPreview={commentPreview}
/>
{showCommentSlot && commentPreview && (
<ActivityCommentSlot
commentPreview={commentPreview}
isReview={commentPreview.isReview}
reviewScore={getReviewScore(entry) ?? entry.metrics?.reviewScore}
workTitle={work.title}
workHref={work.href}
createdDate={commentEntry?.createdDate}
updatedDate={commentEntry?.updatedDate}
/>
)}
</ActivityWorkPanel>
</article>
);
};
66 changes: 66 additions & 0 deletions components/Activity/ActivityCardHeader.tsx
Original file line number Diff line number Diff line change
@@ -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<ActivityCardHeaderProps> = ({ entry }) => {
const message = getActivityHeaderMessage(entry);
const actionIcon = getActionIcon(entry);
const reviewScore = getReviewScore(entry);
const grantAmount = getGrantAmount(entry);
const contribution = getContribution(entry);

return (
<div className="pb-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm leading-tight">
<AuthorTooltip authorId={message.actor.id} placement="bottom">
<Avatar
src={message.actor.profileImage}
alt={message.actor.fullName || 'User'}
size={32}
authorId={message.actor.id}
disableTooltip
className="flex-shrink-0"
/>
</AuthorTooltip>
<ActivityHeaderActionText message={message} />
<FeedEntryIcon name={actionIcon} />
{reviewScore != null && reviewScore > 0 && (
<ReviewScoreStars score={reviewScore} size="sm" />
)}
{grantAmount && <GrantFundingAmount amount={grantAmount} />}
{contribution && (
<ContributionAmount contribution={contribution} className="text-gray-900" />
)}
<Tooltip
content={new Date(entry.timestamp).toLocaleString()}
wrapperClassName="ml-auto flex-shrink-0"
>
<span className="text-xs text-gray-400 cursor-default">
{formatTimeAgo(entry.timestamp)}
</span>
</Tooltip>
</div>
</div>
);
};
39 changes: 39 additions & 0 deletions components/Activity/ActivityCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';

import { FC } from 'react';

export const ActivityCardSkeleton: FC = () => (
<div className="animate-pulse py-5" aria-hidden>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pb-3">
<div className="w-8 h-8 bg-gray-200 rounded-full shrink-0" />
<div className="h-3.5 bg-gray-200 rounded w-24" />
<div className="h-3.5 bg-gray-200 rounded w-32" />
<div className="h-3 bg-gray-200 rounded w-10 ml-auto shrink-0" />
</div>

<div className="flex flex-col md:flex-row md:gap-4">
<div className="hidden md:block w-[160px] min-h-[120px] bg-gray-200 rounded-lg shrink-0" />

<div className="flex-1 min-w-0 flex flex-col gap-2">
<div className="h-[18px] bg-gray-200 rounded w-full md:w-4/5" />
<div className="h-3 bg-gray-200 rounded w-1/2" />

<div className="rounded-lg bg-gray-100 border border-gray-100 px-4 py-3.5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-6 min-w-0">
<div className="flex flex-col gap-1.5">
<div className="h-2.5 bg-gray-200 rounded w-20" />
<div className="h-5 bg-gray-200 rounded w-16" />
</div>
<div className="flex flex-col gap-1.5">
<div className="h-2.5 bg-gray-200 rounded w-14" />
<div className="h-5 bg-gray-200 rounded w-8" />
</div>
</div>
<div className="h-8 w-[72px] bg-gray-200 rounded shrink-0" />
</div>
</div>
</div>
</div>
</div>
);
Loading