Skip to content
Merged
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@ When reviewing code (or writing code that will be reviewed):
- **Avoid confusing naming** - Don't create multiple components with the same name in different locations (e.g., two `AboutMe` components)
- **Remove unused exports** - If a function/constant is only used internally, don't export it
- **Clean up duplicates** - If the same interface/type is defined in multiple places, consolidate to one location and import
- **Activity list modals should be metadata-first** - For lists like reposts/upvotes/history in modals, prefer compact rows that emphasize source/author and engagement. Avoid large content images that dominate the layout unless image content is the primary purpose.
- **Reuse feed/list card primitives first** - Before adding modal-specific list item components, check existing card building blocks (`FeedItemContainer`, `PostCardHeader`, list card primitives) and compose with them.
- **Do not hide accessible data using presentation heuristics** - In UI lists, avoid masking content based on flags like `source.public`; rely on backend access controls and render the data returned by the query.
- **Keep scope tight in design iterations** - When adjusting UI, avoid unrelated behavioral/SEO changes in the same commit unless explicitly requested.

## Node.js Version Upgrade Checklist

Expand Down
133 changes: 133 additions & 0 deletions packages/shared/src/components/modals/RepostListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { ReactElement } from 'react';
import React from 'react';
import { ProfileImageSize } from '../ProfilePicture';
import { SourceAvatar } from '../profile/source/SourceAvatar';
import Link from '../utilities/Link';
import type { Post } from '../../graphql/posts';
import { isSourceUserSource } from '../../graphql/sources';
import { DiscussIcon, LockIcon, UpvoteIcon } from '../icons';
import { largeNumberFormat } from '../../lib/numberFormat';
import { UserShortInfo } from '../profile/UserShortInfo';
import { TimeFormatType, formatDate } from '../../lib/dateFormat';

interface RepostListItemProps {
post: Post;
scrollingContainer?: HTMLElement | null;
appendTooltipTo?: HTMLElement | null;
}

export function RepostListItem({
post,
scrollingContainer,
appendTooltipTo,
}: RepostListItemProps): ReactElement {
const isUserSource = isSourceUserSource(post.source);
const upvotes = post.numUpvotes ?? 0;
const comments = post.numComments ?? 0;
const { author } = post;
const showSquadPreview = !isUserSource && !!post.source;
const isPrivateSquad = showSquadPreview && !post.source.public;

const renderUserInfo = () => {
if (!author) {
return null;
}

const userShortInfoProps = {
user: author,
showDescription: false,
scrollingContainer,
appendTooltipTo,
};

if (author.permalink) {
return (
<Link href={author.permalink}>
<UserShortInfo
{...userShortInfoProps}
className={{ container: 'cursor-pointer' }}
tag="a"
href={author.permalink}
/>
</Link>
);
}

return (
<UserShortInfo
{...userShortInfoProps}
className={{ container: '' }}
tag="div"
/>
);
};

return (
<div className="border-b border-border-subtlest-tertiary px-6 py-5 last:border-b-0">
{/* Squad name + lock + date */}
{showSquadPreview && (
<div className="mb-3 flex items-center gap-1">
<SourceAvatar
source={post.source}
size={ProfileImageSize.XSmall}
className="!mr-0"
/>
{post.source.permalink ? (
<Link href={post.source.permalink}>
<a className="truncate text-text-secondary typo-callout hover:underline">
{post.source.name}
</a>
</Link>
) : (
<span className="truncate text-text-secondary typo-callout">
{post.source.name}
</span>
)}
{isPrivateSquad && (
<LockIcon className="size-3.5 text-text-quaternary" />
)}
{post.createdAt && (
<>
<span className="text-text-tertiary typo-footnote">·</span>
<span className="shrink-0 text-text-tertiary typo-footnote">
{formatDate({
value: post.createdAt,
type: TimeFormatType.Post,
})}
</span>
</>
)}
</div>
)}

{/* User info row */}
{renderUserInfo()}

{/* Post text content */}
{!!post.title &&
(post.commentsPermalink ? (
<Link href={post.commentsPermalink}>
<a className="mt-3 line-clamp-3 block text-text-primary typo-body hover:underline">
{post.title}
</a>
</Link>
) : (
<p className="mt-3 line-clamp-3 text-text-primary typo-body">
{post.title}
</p>
))}

{/* Upvotes and comments */}
<div className="mt-3 flex items-center gap-4 text-text-quaternary typo-callout">
<span className="flex items-center gap-1.5">
<UpvoteIcon className="size-4" />
{largeNumberFormat(upvotes)}
</span>
<span className="flex items-center gap-1.5">
<DiscussIcon className="size-4" />
{largeNumberFormat(comments)}
</span>
</div>
</div>
);
}
81 changes: 81 additions & 0 deletions packages/shared/src/components/modals/RepostsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ReactElement } from 'react';
import React, { useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { RequestQuery } from '../../graphql/common';
import { useRequestProtocol } from '../../hooks/useRequestProtocol';
import type { ModalProps } from './common/Modal';
import { Modal } from './common/Modal';
import InfiniteScrolling, {
checkFetchMore,
} from '../containers/InfiniteScrolling';
import type { Post, PostRepostsData } from '../../graphql/posts';
import { getNextPageParam } from '../../lib/query';
import { FlexCentered } from '../utilities';
import { RepostListItem } from './RepostListItem';

export interface RepostsModalProps extends ModalProps {
requestQuery: RequestQuery<PostRepostsData>;
}

export function RepostsModal({
requestQuery: { queryKey, query, params, options = {} },
...props
}: RepostsModalProps): ReactElement {
const container = useRef<HTMLElement>(null);
const modalRef = useRef<HTMLElement>(null);
const { requestMethod } = useRequestProtocol();
const queryResult = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) =>
requestMethod(
query,
{ ...params, after: pageParam },
{ requestKey: JSON.stringify(queryKey) },
),
initialPageParam: '',
...options,
getNextPageParam: ({ postReposts }) =>
getNextPageParam(postReposts?.pageInfo),
});

const reposts: Post[] =
queryResult.data?.pages.flatMap((page) =>
page.postReposts.edges.map(({ node }) => node),
) ?? [];

return (
<Modal
{...props}
contentRef={(element) => {
modalRef.current = element;
}}
kind={Modal.Kind.FlexibleCenter}
size={Modal.Size.Medium}
>
<Modal.Header title="Reposts" />
<Modal.Body className="!p-0" ref={container}>
<InfiniteScrolling
canFetchMore={checkFetchMore(queryResult)}
isFetchingNextPage={queryResult.isFetchingNextPage}
fetchNextPage={queryResult.fetchNextPage}
>
{reposts.map((post) => (
<RepostListItem
key={post.id}
post={post}
scrollingContainer={container.current}
appendTooltipTo={modalRef.current}
/>
))}
</InfiniteScrolling>
{!queryResult.isPending && reposts.length === 0 && (
<FlexCentered className="p-10 text-text-tertiary typo-callout">
No reposts found
</FlexCentered>
)}
</Modal.Body>
</Modal>
);
}

export default RepostsModal;
4 changes: 4 additions & 0 deletions packages/shared/src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const UpvotedPopupModal = dynamic(
() =>
import(/* webpackChunkName: "upvotedPopupModal" */ './UpvotedPopupModal'),
);
const RepostsModal = dynamic(
() => import(/* webpackChunkName: "repostsModal" */ './RepostsModal'),
);
const UserFollowersModal = dynamic(
() =>
import(/* webpackChunkName: "userFollowersModal" */ './UserFollowersModal'),
Expand Down Expand Up @@ -427,6 +430,7 @@ const AchievementSyncPromptModal = dynamic(
export const modals = {
[LazyModal.SquadMember]: SquadMemberModal,
[LazyModal.UpvotedPopup]: UpvotedPopupModal,
[LazyModal.RepostsPopup]: RepostsModal,
[LazyModal.SquadTour]: SquadTourModal,
[LazyModal.ReadingHistory]: ReadingHistoryModal,
[LazyModal.SquadPromotion]: SquadPromotionModal,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/modals/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum LazyModal {
SquadMember = 'squadMember',
SquadTour = 'squadTour',
UpvotedPopup = 'upvotedPopup',
RepostsPopup = 'repostsPopup',
ReadingHistory = 'readingHistory',
SquadPromotion = 'squadPromotion',
CreateSharedPost = 'createSharedPost',
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/components/post/PostUpvotesCommentsCount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import Link from '../utilities/Link';
import { Button, ButtonSize } from '../buttons/Button';
import { AnalyticsIcon } from '../icons';
import { webappUrl } from '../../lib/constants';
import { POST_REPOSTS_BY_ID_QUERY } from '../../graphql/posts';

const DEFAULT_REPOSTS_PER_PAGE = 20;

interface PostUpvotesCommentsCountProps {
post: Post;
Expand All @@ -28,7 +31,23 @@ export function PostUpvotesCommentsCount({
const upvotes = post.numUpvotes || 0;
const comments = post.numComments || 0;
const awards = post.numAwards || 0;
const reposts = post.numReposts || 0;
const hasAccessToCores = useHasAccessToCores();
const onRepostsClick = () =>
openModal({
type: LazyModal.RepostsPopup,
props: {
requestQuery: {
queryKey: ['postReposts', post.id],
query: POST_REPOSTS_BY_ID_QUERY,
params: {
id: post.id,
first: DEFAULT_REPOSTS_PER_PAGE,
supportedTypes: ['share'],
},
},
},
});

return (
<div
Expand All @@ -52,6 +71,11 @@ export function PostUpvotesCommentsCount({
{` Comment${comments === 1 ? '' : 's'}`}
</span>
)}
{reposts > 0 && (
<ClickableText onClick={onRepostsClick}>
{largeNumberFormat(reposts)} Repost{reposts > 1 ? 's' : ''}
</ClickableText>
)}
{hasAccessToCores && awards > 0 && (
<ClickableText
onClick={() => {
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/components/post/write/ShareLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export function ShareLink({
return onUpdateSubmit(e);
}

if (!preview) {
displayToast('Please provide a valid link first');
return null;
}

const isLinkAlreadyShared = preview.relatedPublicPosts?.length > 0;
const proceedSharingLink =
!isPostingOnMySource ||
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/graphql/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export const SHARED_POST_INFO_FRAGMENT = gql`
numUpvotes
numComments
numAwards
numReposts
videoId
yggdrasilId
creatorTwitter
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/src/graphql/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface Post {
numUpvotes?: number;
numComments?: number;
numAwards?: number;
numReposts?: number;
author?: Author;
scout?: Scout;
read?: boolean;
Expand Down Expand Up @@ -265,6 +266,10 @@ export interface PostData {
relatedCollectionPosts?: Connection<RelatedPost>;
}

export interface PostRepostsData {
postReposts: Connection<Post>;
}

export const RELATED_POSTS_PER_PAGE_DEFAULT = 5;

export const POST_BY_ID_QUERY = gql`
Expand Down Expand Up @@ -339,6 +344,33 @@ export const POST_UPVOTES_BY_ID_QUERY = gql`
}
`;

export const POST_REPOSTS_BY_ID_QUERY = gql`
query PostReposts(
$id: String!
$after: String
$first: Int
$supportedTypes: [String!]
) {
postReposts(
id: $id
after: $after
first: $first
supportedTypes: $supportedTypes
) {
pageInfo {
endCursor
hasNextPage
}
edges {
node {
...SharedPostInfo
}
}
}
}
${SHARED_POST_INFO_FRAGMENT}
`;

export const POST_BY_ID_STATIC_FIELDS_QUERY = gql`
query Post($id: ID!) {
post(id: $id) {
Expand All @@ -354,6 +386,7 @@ export const POST_BY_ID_STATIC_FIELDS_QUERY = gql`
numUpvotes
numComments
numAwards
numReposts
source {
...SourceShortInfo
}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export enum Origin {
UserFollowingList = 'user following list',
UserFollowersList = 'user followers list',
UserUpvotesList = 'user upvotes list',
UserRepostsList = 'user reposts list',
FollowFilter = 'follow filter',
PostSharedBy = 'post shared by',
// Marketing
Expand Down