diff --git a/AGENTS.md b/AGENTS.md index 4375f7bd48..a84f752a75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/shared/src/components/modals/RepostListItem.tsx b/packages/shared/src/components/modals/RepostListItem.tsx new file mode 100644 index 0000000000..2d73c4d32f --- /dev/null +++ b/packages/shared/src/components/modals/RepostListItem.tsx @@ -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 ( + + + + ); + } + + return ( + + ); + }; + + return ( +
+ {/* Squad name + lock + date */} + {showSquadPreview && ( +
+ + {post.source.permalink ? ( + + + {post.source.name} + + + ) : ( + + {post.source.name} + + )} + {isPrivateSquad && ( + + )} + {post.createdAt && ( + <> + ยท + + {formatDate({ + value: post.createdAt, + type: TimeFormatType.Post, + })} + + + )} +
+ )} + + {/* User info row */} + {renderUserInfo()} + + {/* Post text content */} + {!!post.title && + (post.commentsPermalink ? ( + + + {post.title} + + + ) : ( +

+ {post.title} +

+ ))} + + {/* Upvotes and comments */} +
+ + + {largeNumberFormat(upvotes)} + + + + {largeNumberFormat(comments)} + +
+
+ ); +} diff --git a/packages/shared/src/components/modals/RepostsModal.tsx b/packages/shared/src/components/modals/RepostsModal.tsx new file mode 100644 index 0000000000..bec9579de3 --- /dev/null +++ b/packages/shared/src/components/modals/RepostsModal.tsx @@ -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; +} + +export function RepostsModal({ + requestQuery: { queryKey, query, params, options = {} }, + ...props +}: RepostsModalProps): ReactElement { + const container = useRef(null); + const modalRef = useRef(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 ( + { + modalRef.current = element; + }} + kind={Modal.Kind.FlexibleCenter} + size={Modal.Size.Medium} + > + + + + {reposts.map((post) => ( + + ))} + + {!queryResult.isPending && reposts.length === 0 && ( + + No reposts found + + )} + + + ); +} + +export default RepostsModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index e5db0f6d8b..29edd4518e 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -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'), @@ -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, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 6db92e1346..a87adfe33e 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -28,6 +28,7 @@ export enum LazyModal { SquadMember = 'squadMember', SquadTour = 'squadTour', UpvotedPopup = 'upvotedPopup', + RepostsPopup = 'repostsPopup', ReadingHistory = 'readingHistory', SquadPromotion = 'squadPromotion', CreateSharedPost = 'createSharedPost', diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx index 0a4d23c428..e9205905b8 100644 --- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx +++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx @@ -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; @@ -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 (
)} + {reposts > 0 && ( + + {largeNumberFormat(reposts)} Repost{reposts > 1 ? 's' : ''} + + )} {hasAccessToCores && awards > 0 && ( { diff --git a/packages/shared/src/components/post/write/ShareLink.tsx b/packages/shared/src/components/post/write/ShareLink.tsx index 76f1b68b98..e97d11eab6 100644 --- a/packages/shared/src/components/post/write/ShareLink.tsx +++ b/packages/shared/src/components/post/write/ShareLink.tsx @@ -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 || diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index dc45e77b34..9184e70a6d 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -329,6 +329,7 @@ export const SHARED_POST_INFO_FRAGMENT = gql` numUpvotes numComments numAwards + numReposts videoId yggdrasilId creatorTwitter diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 8ef5c7adbb..777f3559d5 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -166,6 +166,7 @@ export interface Post { numUpvotes?: number; numComments?: number; numAwards?: number; + numReposts?: number; author?: Author; scout?: Scout; read?: boolean; @@ -265,6 +266,10 @@ export interface PostData { relatedCollectionPosts?: Connection; } +export interface PostRepostsData { + postReposts: Connection; +} + export const RELATED_POSTS_PER_PAGE_DEFAULT = 5; export const POST_BY_ID_QUERY = gql` @@ -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) { @@ -354,6 +386,7 @@ export const POST_BY_ID_STATIC_FIELDS_QUERY = gql` numUpvotes numComments numAwards + numReposts source { ...SourceShortInfo } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 6ee6c7883d..e47af6f9d3 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -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