From 26701fae092ff85663fd2471e4d78dd52f3a4683 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 17 Feb 2026 10:17:31 +0200 Subject: [PATCH 01/34] feat: refine X repost cards in grid and list Align social Twitter repost cards across grid and list by moving repost identity to metadata, improving embedded tweet presentation, and standardizing the Read on X CTA for clearer feed UX. Co-authored-by: Cursor --- .../cards/common/list/PostCardHeader.tsx | 11 +- .../socialTwitter/SocialTwitterGrid.spec.tsx | 109 +++++++++- .../cards/socialTwitter/SocialTwitterGrid.tsx | 173 +++++++++++++--- .../socialTwitter/SocialTwitterList.spec.tsx | 49 +++++ .../cards/socialTwitter/SocialTwitterList.tsx | 189 +++++++++++++++--- 5 files changed, 468 insertions(+), 63 deletions(-) diff --git a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx index 1835a9e802..ee5dae7ffe 100644 --- a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx @@ -36,6 +36,8 @@ interface CardHeaderProps { onReadArticleClick?: (e: React.MouseEvent) => unknown; postLink?: string; openNewTab?: boolean; + readButtonContent?: string; + readButtonIcon?: ReactElement; metadata?: { topLabel?: PostMetadataProps['topLabel']; bottomLabel?: PostMetadataProps['bottomLabel']; @@ -51,6 +53,8 @@ export const PostCardHeader = ({ children, postLink, openNewTab, + readButtonContent, + readButtonIcon, metadata, }: CardHeaderProps): ReactElement => { const isFeedPreview = useFeedPreviewMode(); @@ -63,7 +67,8 @@ export const PostCardHeader = ({ const isUserSource = isSourceUserSource(post.source); const showCTA = !isFeedPreview && - [PostType.Article, PostType.VideoYouTube].includes(post.type); + ([PostType.Article, PostType.VideoYouTube].includes(post.type) || + !!readButtonContent); return ( <> @@ -107,10 +112,10 @@ export const PostCardHeader = ({ <> {showCTA && ( } + icon={readButtonIcon ?? } href={postLink} onClick={onReadArticleClick} openNewTab={openNewTab} diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx index 944cf08274..da54180889 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx @@ -26,6 +26,13 @@ jest.mock('../../../hooks', () => { }; }); +jest.mock('../common/PostTags', () => ({ + __esModule: true, + default: ({ post }: { post: { tags?: string[] } }) => ( +
{post.tags?.join(',')}
+ ), +})); + const basePost: Post = { ...sharePost, type: PostType.SocialTwitter, @@ -67,11 +74,17 @@ const renderComponent = (props: Partial = {}) => , ); -it('should render twitter action link using post permalink', async () => { +it('should render top action link using post comments permalink', async () => { + renderComponent(); + + const link = await screen.findByRole('link', { name: 'Read on' }); + expect(link).toHaveAttribute('href', basePost.commentsPermalink); +}); + +it('should render source handle next to metadata date', async () => { renderComponent(); - const link = await screen.findByLabelText('View tweet on X'); - expect(link).toHaveAttribute('href', basePost.permalink); + expect((await screen.findAllByText(/@avengers/)).length).toBeGreaterThan(0); }); it('should render thread content without duplicating title line', async () => { @@ -113,8 +126,14 @@ it('should render quote/repost detail from shared post', async () => { }, }); + expect((await screen.findAllByText(/@avengers/)).length).toBeGreaterThan(0); + expect((await screen.findAllByText(/@devrelweekly/)).length).toBeGreaterThan( + 0, + ); expect(await screen.findByText('DevRel Weekly')).toBeInTheDocument(); - expect(await screen.findByText('@devrelweekly')).toBeInTheDocument(); + expect( + await screen.findByAltText("devrelweekly's profile"), + ).toBeInTheDocument(); expect( await screen.findByText('Referenced tweet content'), ).toBeInTheDocument(); @@ -144,11 +163,91 @@ it('should use creatorTwitter when shared source is unknown', async () => { }, }); - expect(await screen.findByText('@shared_creator')).toBeInTheDocument(); + expect( + (await screen.findAllByText(/@shared_creator/)).length, + ).toBeGreaterThan(0); expect(screen.queryByText('@creator_twitter')).not.toBeInTheDocument(); expect(screen.queryByText('@unknown')).not.toBeInTheDocument(); }); +it('should use creatorTwitter when source is unknown for metadata handle', async () => { + renderComponent({ + post: { + ...basePost, + source: { + ...basePost.source, + id: 'unknown', + handle: 'unknown', + }, + creatorTwitter: 'root_creator', + }, + }); + + expect((await screen.findAllByText(/@root_creator/)).length).toBeGreaterThan( + 0, + ); + expect(screen.queryByText('@unknown')).not.toBeInTheDocument(); +}); + +it('should hide headline and tags for repost cards without repost text', async () => { + renderComponent({ + post: { + ...basePost, + subType: 'repost', + title: + '@bcherny: RT @ycombinator: Today, startups are not winning by hiring faster', + content: null, + contentHtml: null, + tags: ['tagaa', 'tagbb'], + sharedPost: { + ...sharePost.sharedPost, + source: { + ...sharePost.sharedPost.source, + name: 'Y Combinator', + handle: 'ycombinator', + }, + title: 'Referenced tweet content', + }, + }, + }); + + expect( + screen.queryByText( + '@bcherny: RT @ycombinator: Today, startups are not winning by hiring faster', + ), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('post-tags')).not.toBeInTheDocument(); + expect(await screen.findByText('@avengers')).toBeInTheDocument(); + expect(await screen.findByText('Y Combinator')).toBeInTheDocument(); + expect((await screen.findAllByText(/@ycombinator/)).length).toBeGreaterThan( + 0, + ); + expect( + await screen.findByText('Referenced tweet content'), + ).toBeInTheDocument(); +}); + +it('should keep headline and tags for repost cards with repost text', async () => { + renderComponent({ + post: { + ...basePost, + subType: 'repost', + title: '@bcherny: RT @ycombinator: Repost with context', + content: 'My thoughts on this', + tags: ['tagaa', 'tagbb'], + sharedPost: { + ...sharePost.sharedPost, + title: 'Referenced tweet content', + }, + }, + }); + + expect( + await screen.findByText('RT @ycombinator: Repost with context'), + ).toBeInTheDocument(); + expect(await screen.findByTestId('post-tags')).toBeInTheDocument(); +}); + it('should keep actions visible when there is no media and no shared post detail', async () => { renderComponent({ post: { diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx index 33da507d26..e9bd82cdd7 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx @@ -1,7 +1,11 @@ import type { ReactElement, Ref } from 'react'; import React, { forwardRef } from 'react'; import type { PostCardProps } from '../common/common'; -import { Container, getGroupedHoverContainer } from '../common/common'; +import { + Container, + getGroupedHoverContainer, + Separator, +} from '../common/common'; import FeedItemContainer from '../common/FeedItemContainer'; import { CardHeader, @@ -15,10 +19,11 @@ import SourceButton from '../common/SourceButton'; import PostMetadata from '../common/PostMetadata'; import ActionButtons from '../common/ActionButtons'; import PostTags from '../common/PostTags'; -import { ProfileImageSize } from '../../ProfilePicture'; +import { ReadArticleButton } from '../common/ReadArticleButton'; +import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { ProfileImageLink } from '../../profile/ProfileImageLink'; import { PostOptionButton } from '../../../features/posts/PostOptionButton'; -import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ButtonVariant } from '../../buttons/Button'; import { IconSize } from '../../Icon'; import { TwitterIcon } from '../../icons'; import { useFeedPreviewMode } from '../../../hooks'; @@ -65,6 +70,45 @@ const normalizeThreadBody = ({ return bodyWithoutTitle || undefined; }; +const getPostText = ({ + content, + contentHtml, +}: { + content?: string; + contentHtml?: string; +}): string | undefined => { + const rawText = content || (contentHtml ? stripHtmlTags(contentHtml) : null); + const trimmedText = rawText?.trim(); + return trimmedText?.length ? trimmedText : undefined; +}; + +const removeHandlePrefixFromTitle = ({ + title, + sourceHandle, + authorHandle, +}: { + title?: string; + sourceHandle?: string; + authorHandle?: string; +}): string | undefined => { + if (!title) { + return title; + } + + const handlePrefixes = [sourceHandle, authorHandle] + .filter(Boolean) + .map((handle) => `@${handle}:`); + + const matchedPrefix = handlePrefixes.find((prefix) => + title.startsWith(prefix), + ); + if (matchedPrefix) { + return title.slice(matchedPrefix.length).trim(); + } + + return title.replace(/^@[A-Za-z0-9_]+:\s*/, '').trim(); +}; + export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( { post, @@ -88,23 +132,71 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( const shouldHideMedia = post.subType === 'thread'; const showQuoteDetail = isQuoteLike; const showMediaDetail = !isQuoteLike && !shouldHideMedia && !!post.image; - const title = post.title || post.sharedPost?.title; + const repostText = + post.subType === 'repost' + ? getPostText({ + content: post.content, + contentHtml: post.contentHtml, + }) + : undefined; + const shouldHideRepostHeadlineAndTags = + post.subType === 'repost' && !repostText; + const quoteDetailsContainerClass = shouldHideRepostHeadlineAndTags + ? 'mx-1 mb-1 mt-2 min-h-[13.5rem] flex-1 rounded-12 border border-border-subtlest-tertiary p-3' + : 'mx-1 mb-1 mt-2 h-40 rounded-12 border border-border-subtlest-tertiary p-3'; + const quoteDetailsTextClampClass = shouldHideRepostHeadlineAndTags + ? 'line-clamp-[10]' + : 'line-clamp-5'; + const rawTitle = post.title || post.sharedPost?.title; const cardTags = post.tags?.length ? post.tags : post.sharedPost?.tags; const threadBody = post.subType === 'thread' ? normalizeThreadBody({ - title, + title: rawTitle, content: post.content, contentHtml: post.contentHtml, }) : undefined; - const tweetUrl = post.permalink || post.commentsPermalink; const quotedHandle = post.sharedPost?.source?.id === UNKNOWN_SOURCE_ID ? post.sharedPost?.creatorTwitter || post.creatorTwitter || post.sharedPost?.author?.username : post.sharedPost?.source?.handle; + const quotedSourceName = post.sharedPost?.source?.name; + const isUnknownQuotedSourceName = + quotedSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; + const embeddedTweetName = + !isUnknownQuotedSourceName && quotedSourceName + ? quotedSourceName + : post.sharedPost?.author?.name; + const embeddedTweetAvatar = + post.sharedPost?.author?.image || post.sharedPost?.source?.image; + const embeddedTweetAvatarUser = embeddedTweetAvatar + ? { + id: + post.sharedPost?.author?.id || + post.sharedPost?.source?.id || + quotedHandle || + 'shared-post-avatar', + image: embeddedTweetAvatar, + username: quotedHandle, + name: embeddedTweetName, + } + : null; + const sourceHandle = + post.source?.id === UNKNOWN_SOURCE_ID + ? post.creatorTwitter || post.author?.username + : post.source?.handle; + const title = removeHandlePrefixFromTitle({ + title: rawTitle, + sourceHandle, + authorHandle: post.author?.username, + }); + const metadataHandles = + post.subType === 'repost' + ? [sourceHandle].filter(Boolean) + : [...new Set([sourceHandle, quotedHandle].filter(Boolean))]; const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -143,52 +235,75 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid(
{!isFeedPreview && ( -
- - {title} - + {!shouldHideRepostHeadlineAndTags && ( + + {title} + + )} - {!!cardTags?.length && ( + {!shouldHideRepostHeadlineAndTags && !!cardTags?.length && ( )} + > + {metadataHandles.map((handle, index) => ( + + {(!!post.createdAt || index > 0) && }@{handle} + + ))} + {threadBody && (

{threadBody}

)} -
{showQuoteDetail ? ( -
-

- {post.sharedPost?.source?.name || 'Referenced post'} -

- {!!quotedHandle && ( -

- @{quotedHandle} -

- )} -

+

+
+ {!!embeddedTweetAvatarUser && ( + + )} +
+ {!!embeddedTweetName && ( +

+ {embeddedTweetName} +

+ )} + {!!quotedHandle && ( +

+ @{quotedHandle} +

+ )} +
+
+

{post.sharedPost?.title}

diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx index 9a665d7eb6..e9871d7d36 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx @@ -15,6 +15,13 @@ jest.mock('next/router', () => ({ useRouter: jest.fn(), })); +jest.mock('../common/PostTags', () => ({ + __esModule: true, + default: ({ post }: { post: { tags?: string[] } }) => ( +
{post.tags?.join(',')}
+ ), +})); + const basePost: Post = { ...sharePost, type: PostType.SocialTwitter, @@ -73,6 +80,9 @@ it('should hide image and show referenced tweet block for shared social tweets', expect(await screen.findByText('Referenced post')).toBeInTheDocument(); expect(await screen.findByText('@devrelweekly')).toBeInTheDocument(); + expect( + await screen.findByAltText("devrelweekly's profile"), + ).toBeInTheDocument(); expect(await screen.findByText('Referenced tweet body')).toBeInTheDocument(); expect(screen.queryByAltText('Post cover image')).not.toBeInTheDocument(); }); @@ -80,5 +90,44 @@ it('should hide image and show referenced tweet block for shared social tweets', it('should render image for non-shared social tweets', async () => { renderComponent(); + expect( + await screen.findByRole('link', { name: 'Read on' }), + ).toBeInTheDocument(); expect(await screen.findByAltText('Post cover image')).toBeInTheDocument(); }); + +it('should hide headline and tags for repost cards without repost text', async () => { + renderComponent({ + post: { + ...basePost, + subType: 'repost', + title: + '@bcherny: RT @ycombinator: Today, startups are not winning by hiring faster', + content: null, + contentHtml: null, + tags: ['tagaa', 'tagbb'], + sharedPost: { + ...sharePost.sharedPost, + type: PostType.SocialTwitter, + title: 'Referenced tweet body', + source: { + ...sharePost.sharedPost.source, + name: 'Y Combinator', + handle: 'ycombinator', + }, + }, + }, + }); + + expect( + screen.queryByText( + '@bcherny: RT @ycombinator: Today, startups are not winning by hiring faster', + ), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('post-tags')).not.toBeInTheDocument(); + expect(await screen.findByText('@avengers')).toBeInTheDocument(); + expect(await screen.findByText('Y Combinator')).toBeInTheDocument(); + expect((await screen.findAllByText(/@ycombinator/)).length).toBeGreaterThan( + 0, + ); +}); diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx index dba1d8b909..fc493f7e57 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -2,7 +2,7 @@ import type { ReactElement, Ref } from 'react'; import React, { forwardRef, useMemo, useRef } from 'react'; import classNames from 'classnames'; import type { PostCardProps } from '../common/common'; -import { Container } from '../common/common'; +import { Container, Separator } from '../common/common'; import { useFeedPreviewMode, useTruncatedSummary, @@ -23,10 +23,52 @@ import { isSourceUserSource } from '../../../graphql/sources'; import { PostType } from '../../../graphql/posts'; import PostTags from '../common/PostTags'; import SourceButton from '../common/SourceButton'; -import { ProfileImageSize } from '../../ProfilePicture'; +import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; +import { IconSize } from '../../Icon'; +import { TwitterIcon } from '../../icons'; const UNKNOWN_SOURCE_ID = 'unknown'; +const getPostText = ({ + content, + contentHtml, +}: { + content?: string; + contentHtml?: string; +}): string | undefined => { + const rawText = + content || (contentHtml ? sanitizeMessage(contentHtml, []) : null); + const trimmedText = rawText?.trim(); + return trimmedText?.length ? trimmedText : undefined; +}; + +const removeHandlePrefixFromTitle = ({ + title, + sourceHandle, + authorHandle, +}: { + title?: string; + sourceHandle?: string; + authorHandle?: string; +}): string | undefined => { + if (!title) { + return title; + } + + const handlePrefixes = [sourceHandle, authorHandle] + .filter(Boolean) + .map((handle) => `@${handle}:`); + + const matchedPrefix = handlePrefixes.find((prefix) => + title.startsWith(prefix), + ); + if (matchedPrefix) { + return title.slice(matchedPrefix.length).trim(); + } + + return title.replace(/^@[A-Za-z0-9_]+:\s*/, '').trim(); +}; + export const SocialTwitterList = forwardRef(function SocialTwitterList( { post, @@ -38,6 +80,7 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( onBookmarkClick, onShare, children, + openNewTab, enableSourceHeader = false, domProps = {}, eagerLoadImage = false, @@ -60,12 +103,58 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( const postForTags = post.tags?.length ? post : post.sharedPost || post; const showReferenceTweet = post.sharedPost?.type === PostType.SocialTwitter; const showMediaCover = !!image && !showReferenceTweet; + const repostText = + post.subType === 'repost' + ? getPostText({ + content: post.content, + contentHtml: post.contentHtml, + }) + : undefined; + const shouldHideRepostHeadlineAndTags = + post.subType === 'repost' && !repostText; + const quoteDetailsTextClampClass = shouldHideRepostHeadlineAndTags + ? 'line-clamp-8' + : 'line-clamp-4'; const referenceHandle = post.sharedPost?.source?.id === UNKNOWN_SOURCE_ID ? post.sharedPost?.creatorTwitter || post.creatorTwitter || post.sharedPost?.author?.username : post.sharedPost?.source?.handle; + const sourceHandle = + post.source?.id === UNKNOWN_SOURCE_ID + ? post.creatorTwitter || post.author?.username + : post.source?.handle; + const metadataHandles = + post.subType === 'repost' + ? [sourceHandle].filter(Boolean) + : [...new Set([sourceHandle, referenceHandle].filter(Boolean))]; + const cleanedTitle = removeHandlePrefixFromTitle({ + title: truncatedTitle, + sourceHandle, + authorHandle: post.author?.username, + }); + const quotedSourceName = post.sharedPost?.source?.name; + const isUnknownQuotedSourceName = + quotedSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; + const embeddedTweetName = + !isUnknownQuotedSourceName && quotedSourceName + ? quotedSourceName + : post.sharedPost?.author?.name; + const embeddedTweetAvatar = + post.sharedPost?.author?.image || post.sharedPost?.source?.image; + const embeddedTweetAvatarUser = embeddedTweetAvatar + ? { + id: + post.sharedPost?.author?.id || + post.sharedPost?.source?.id || + referenceHandle || + 'shared-post-avatar', + image: embeddedTweetAvatar, + username: referenceHandle, + name: embeddedTweetName, + } + : null; const actionButtons = ( @@ -127,7 +216,27 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( bookmarked={post.bookmarked} > - + + {metadataHandles.map((handle, index) => ( + + {index > 0 && }@{handle} + + ))} + + ) : ( + metadata.bottomLabel + ), + }} + postLink={post.commentsPermalink} + openNewTab={openNewTab} + readButtonContent="Read on" + readButtonIcon={} + > {!isUserSource && !!post?.source && ( -
- - {truncatedTitle} - +
+ {!shouldHideRepostHeadlineAndTags && ( + + {cleanedTitle} + + )}
-
- {post.clickbaitTitleDetected && } - -
+ {!shouldHideRepostHeadlineAndTags && ( +
+ {post.clickbaitTitleDetected && } + +
+ )} + {showReferenceTweet && ( +
+
+ {!!embeddedTweetAvatarUser && ( + + )} +
+ {!!embeddedTweetName && ( +

+ {embeddedTweetName} +

+ )} + {!!referenceHandle && ( +

+ @{referenceHandle} +

+ )} +
+
+

+ {post.sharedPost?.title} +

+
+ )}
{!isMobile && actionButtons}
@@ -165,21 +317,6 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( }} /> )} - {showReferenceTweet && ( -
-

- {post.sharedPost?.source?.name || 'Referenced post'} -

- {!!referenceHandle && ( -

- @{referenceHandle} -

- )} -

- {post.sharedPost?.title} -

-
- )} {isMobile && actionButtons} From e96137c89cbfc5ad70512cab36673aade4672798 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 17 Feb 2026 14:59:30 +0200 Subject: [PATCH 02/34] feat: refine social X repost UI consistency Align social X repost presentation across grid, list, and modal so author metadata, embed typography, and CTA behavior are consistent and clearer. This also improves embedded avatar handling with better fallbacks and repost labeling to reduce confusing placeholders. Co-authored-by: Cursor --- .../cards/common/list/PostCardHeader.tsx | 1 + .../cards/common/list/PostMetadata.tsx | 22 +- .../cards/socialTwitter/SocialTwitterGrid.tsx | 75 ++++-- .../cards/socialTwitter/SocialTwitterList.tsx | 81 +++++-- .../components/post/MarkdownPostContent.tsx | 53 ++++- .../src/components/post/PostHeaderActions.tsx | 28 ++- .../src/components/post/SharePostContent.tsx | 214 +++++++++++++++++- .../post/SocialTwitterPostContent.tsx | 28 ++- 8 files changed, 438 insertions(+), 64 deletions(-) diff --git a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx index ee5dae7ffe..8c4e7aee58 100644 --- a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx @@ -41,6 +41,7 @@ interface CardHeaderProps { metadata?: { topLabel?: PostMetadataProps['topLabel']; bottomLabel?: PostMetadataProps['bottomLabel']; + dateFirst?: PostMetadataProps['dateFirst']; }; } diff --git a/packages/shared/src/components/cards/common/list/PostMetadata.tsx b/packages/shared/src/components/cards/common/list/PostMetadata.tsx index e33f488d31..4be3c9e03b 100644 --- a/packages/shared/src/components/cards/common/list/PostMetadata.tsx +++ b/packages/shared/src/components/cards/common/list/PostMetadata.tsx @@ -18,6 +18,7 @@ export interface PostMetadataProps { topLabel?: ReactElement | string; bottomLabel?: ReactElement | string; createdAt?: string; + dateFirst?: boolean; } export default function PostMetadata({ @@ -25,6 +26,7 @@ export default function PostMetadata({ createdAt, topLabel, bottomLabel, + dateFirst, }: PostMetadataProps): ReactElement { const { boostedBy } = useFeedCardContext(); const promotedText = useScrambler( @@ -47,10 +49,22 @@ export default function PostMetadata({ )} {!!boostedBy && !!bottomLabel && } - {bottomLabel} - {(!!bottomLabel || !!boostedBy) && !!createdAt && } - {!!createdAt && ( - + {dateFirst ? ( + <> + {!!createdAt && ( + + )} + {!!createdAt && !!bottomLabel && } + {bottomLabel} + + ) : ( + <> + {bottomLabel} + {(!!bottomLabel || !!boostedBy) && !!createdAt && } + {!!createdAt && ( + + )} + )}
diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx index e9bd82cdd7..d401171d0b 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx @@ -29,10 +29,20 @@ import { TwitterIcon } from '../../icons'; import { useFeedPreviewMode } from '../../../hooks'; import { isSourceUserSource } from '../../../graphql/sources'; import { stripHtmlTags } from '../../../lib/strings'; +import { fallbackImages } from '../../../lib/config'; +import { cloudinarySquadsImageFallback } from '../../../lib/image'; const HeaderActions = getGroupedHoverContainer('span'); const quoteLikeSubTypes = ['quote', 'repost']; const UNKNOWN_SOURCE_ID = 'unknown'; +const EMBEDDED_TWEET_AVATAR_FALLBACK = fallbackImages.avatar.replace( + 't_logo,', + '', +); +const isSquadPlaceholderAvatar = (image?: string): boolean => + !!image && + (image === cloudinarySquadsImageFallback || + image.includes('squad_placeholder')); const normalizeThreadBody = ({ title, @@ -82,6 +92,13 @@ const getPostText = ({ return trimmedText?.length ? trimmedText : undefined; }; +const formatHandleAsDisplayName = (handle: string): string => + handle + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); + const removeHandlePrefixFromTitle = ({ title, sourceHandle, @@ -170,20 +187,28 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( !isUnknownQuotedSourceName && quotedSourceName ? quotedSourceName : post.sharedPost?.author?.name; + const embeddedTweetDisplayName = + embeddedTweetName || + (quotedHandle && formatHandleAsDisplayName(quotedHandle)); + const embeddedTweetSourceAvatar = isSquadPlaceholderAvatar( + post.sharedPost?.source?.image, + ) + ? undefined + : post.sharedPost?.source?.image; const embeddedTweetAvatar = - post.sharedPost?.author?.image || post.sharedPost?.source?.image; - const embeddedTweetAvatarUser = embeddedTweetAvatar - ? { - id: - post.sharedPost?.author?.id || - post.sharedPost?.source?.id || - quotedHandle || - 'shared-post-avatar', - image: embeddedTweetAvatar, - username: quotedHandle, - name: embeddedTweetName, - } - : null; + post.sharedPost?.author?.image || + embeddedTweetSourceAvatar || + EMBEDDED_TWEET_AVATAR_FALLBACK; + const embeddedTweetAvatarUser = { + id: + post.sharedPost?.author?.id || + post.sharedPost?.source?.id || + quotedHandle || + 'shared-post-avatar', + image: embeddedTweetAvatar, + username: quotedHandle, + name: embeddedTweetName, + }; const sourceHandle = post.source?.id === UNKNOWN_SOURCE_ID ? post.creatorTwitter || post.author?.username @@ -197,6 +222,9 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( post.subType === 'repost' ? [sourceHandle].filter(Boolean) : [...new Set([sourceHandle, quotedHandle].filter(Boolean))]; + const embeddedTweetTextColorClass = post.read + ? 'text-text-tertiary' + : 'text-text-primary'; const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -265,6 +293,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( {metadataHandles.map((handle, index) => ( {(!!post.createdAt || index > 0) && }@{handle} + {post.subType === 'repost' && index === 0 ? ' reposted' : ''} ))} @@ -280,19 +309,27 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( {!!embeddedTweetAvatarUser && ( )}
- {!!embeddedTweetName && ( -

- {embeddedTweetName} + {!!embeddedTweetDisplayName && ( +

+ {embeddedTweetDisplayName}

)} {!!quotedHandle && ( -

+

@{quotedHandle}

)} @@ -300,7 +337,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid(

diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx index fc493f7e57..fa8a514e71 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -26,8 +26,18 @@ import SourceButton from '../common/SourceButton'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { IconSize } from '../../Icon'; import { TwitterIcon } from '../../icons'; +import { fallbackImages } from '../../../lib/config'; +import { cloudinarySquadsImageFallback } from '../../../lib/image'; const UNKNOWN_SOURCE_ID = 'unknown'; +const EMBEDDED_TWEET_AVATAR_FALLBACK = fallbackImages.avatar.replace( + 't_logo,', + '', +); +const isSquadPlaceholderAvatar = (image?: string): boolean => + !!image && + (image === cloudinarySquadsImageFallback || + image.includes('squad_placeholder')); const getPostText = ({ content, @@ -42,6 +52,13 @@ const getPostText = ({ return trimmedText?.length ? trimmedText : undefined; }; +const formatHandleAsDisplayName = (handle: string): string => + handle + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); + const removeHandlePrefixFromTitle = ({ title, sourceHandle, @@ -141,20 +158,31 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( !isUnknownQuotedSourceName && quotedSourceName ? quotedSourceName : post.sharedPost?.author?.name; + const embeddedTweetDisplayName = + embeddedTweetName || + (referenceHandle && formatHandleAsDisplayName(referenceHandle)); + const embeddedTweetSourceAvatar = isSquadPlaceholderAvatar( + post.sharedPost?.source?.image, + ) + ? undefined + : post.sharedPost?.source?.image; const embeddedTweetAvatar = - post.sharedPost?.author?.image || post.sharedPost?.source?.image; - const embeddedTweetAvatarUser = embeddedTweetAvatar - ? { - id: - post.sharedPost?.author?.id || - post.sharedPost?.source?.id || - referenceHandle || - 'shared-post-avatar', - image: embeddedTweetAvatar, - username: referenceHandle, - name: embeddedTweetName, - } - : null; + post.sharedPost?.author?.image || + embeddedTweetSourceAvatar || + EMBEDDED_TWEET_AVATAR_FALLBACK; + const embeddedTweetAvatarUser = { + id: + post.sharedPost?.author?.id || + post.sharedPost?.source?.id || + referenceHandle || + 'shared-post-avatar', + image: embeddedTweetAvatar, + username: referenceHandle, + name: embeddedTweetName, + }; + const embeddedTweetTextColorClass = post.read + ? 'text-text-tertiary' + : 'text-text-primary'; const actionButtons = ( @@ -220,11 +248,15 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( post={post} metadata={{ ...metadata, + dateFirst: true, bottomLabel: metadataHandles.length ? ( <> {metadataHandles.map((handle, index) => ( {index > 0 && }@{handle} + {post.subType === 'repost' && index === 0 + ? ' reposted' + : ''} ))} @@ -271,19 +303,29 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( {!!embeddedTweetAvatarUser && ( )}

- {!!embeddedTweetName && ( -

- {embeddedTweetName} + {!!embeddedTweetDisplayName && ( +

+ {embeddedTweetDisplayName}

)} {!!referenceHandle && ( -

+

@{referenceHandle}

)} @@ -291,7 +333,8 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList(

diff --git a/packages/shared/src/components/post/MarkdownPostContent.tsx b/packages/shared/src/components/post/MarkdownPostContent.tsx index 06b236b90b..00dea0e926 100644 --- a/packages/shared/src/components/post/MarkdownPostContent.tsx +++ b/packages/shared/src/components/post/MarkdownPostContent.tsx @@ -15,6 +15,35 @@ interface MarkdownPostContentProps { post: Post; } +const UNKNOWN_SOURCE_ID = 'unknown'; + +const removeHandlePrefixFromTitle = ({ + title, + sourceHandle, + authorHandle, +}: { + title?: string; + sourceHandle?: string; + authorHandle?: string; +}): string | undefined => { + if (!title) { + return title; + } + + const handlePrefixes = [sourceHandle, authorHandle] + .filter(Boolean) + .map((handle) => `@${handle}:`); + + const matchedPrefix = handlePrefixes.find((prefix) => + title.startsWith(prefix), + ); + if (matchedPrefix) { + return title.slice(matchedPrefix.length).trim(); + } + + return title.replace(/^@[A-Za-z0-9_]+:\s*/, '').trim(); +}; + export const MarkdownPostImage = ({ imgSrc, className, @@ -38,13 +67,31 @@ export const MarkdownPostImage = ({ function MarkdownPostContent({ post }: MarkdownPostContentProps): ReactElement { const { title } = useSmartTitle(post); const hasVideo = !!post.flags?.coverVideo; + const sourceHandle = + post.source?.id === UNKNOWN_SOURCE_ID + ? post.creatorTwitter || post.author?.username + : post.source?.handle; + const cleanedTitle = + post.type === PostType.SocialTwitter + ? removeHandlePrefixFromTitle({ + title, + sourceHandle, + authorHandle: post.author?.username, + }) + : title; return ( <>

-

- {title} -

+ {post.type === PostType.SocialTwitter ? ( +

+ {cleanedTitle} +

+ ) : ( +

+ {cleanedTitle} +

+ )} {post.clickbaitTitleDetected && }
{post.type === PostType.Freeform && ( diff --git a/packages/shared/src/components/post/PostHeaderActions.tsx b/packages/shared/src/components/post/PostHeaderActions.tsx index 35a5e95648..55064b64ad 100644 --- a/packages/shared/src/components/post/PostHeaderActions.tsx +++ b/packages/shared/src/components/post/PostHeaderActions.tsx @@ -1,14 +1,15 @@ import type { ReactElement } from 'react'; import React, { useContext } from 'react'; import classNames from 'classnames'; -import { OpenLinkIcon } from '../icons'; +import { OpenLinkIcon, TwitterIcon } from '../icons'; import { getReadPostButtonText, + isSocialTwitterPost, isInternalReadType, PostType, } from '../../graphql/posts'; import classed from '../../lib/classed'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonIconPosition, ButtonVariant } from '../buttons/Button'; import SettingsContext from '../../contexts/SettingsContext'; import type { PostHeaderActionsProps } from './common'; import { PostMenuOptions } from './PostMenuOptions'; @@ -18,6 +19,7 @@ import { useViewSizeClient, ViewSize } from '../../hooks'; import { BoostPostButton } from '../../features/boost/BoostButton'; import { Tooltip } from '../tooltip/Tooltip'; import { useShowBoostButton } from '../../features/boost/useShowBoostButton'; +import { IconSize } from '../Icon'; const Container = classed('div', 'flex flex-row items-center'); @@ -34,7 +36,20 @@ export function PostHeaderActions({ }: PostHeaderActionsProps): ReactElement { const { openNewTab } = useContext(SettingsContext); const isMobile = useViewSizeClient(ViewSize.MobileXL); - const readButtonText = getReadPostButtonText(post); + const isSocialTwitter = + isSocialTwitterPost(post) || + post.sharedPost?.type === PostType.SocialTwitter; + const readButtonText = isSocialTwitter + ? 'Read on' + : getReadPostButtonText(post); + const readButtonHref = isSocialTwitter + ? post.commentsPermalink + : post.sharedPost?.permalink ?? post.permalink; + const readButtonIcon = isSocialTwitter ? ( + + ) : ( + + ); const isCollection = post?.type === PostType.Collection; const isInternalReadTyped = isInternalReadType(post); const isBoostButtonVisible = useShowBoostButton({ post }); @@ -55,9 +70,12 @@ export function PostHeaderActions({ : ButtonVariant.Secondary } tag="a" - href={post.sharedPost?.permalink ?? post.permalink} + href={readButtonHref} target={openNewTab ? '_blank' : '_self'} - icon={} + icon={readButtonIcon} + iconPosition={ + isSocialTwitter ? ButtonIconPosition.Right : undefined + } onClick={onReadArticle} data-testid="postActionsRead" size={buttonSize} diff --git a/packages/shared/src/components/post/SharePostContent.tsx b/packages/shared/src/components/post/SharePostContent.tsx index 5113e59d91..4fa0b181a2 100644 --- a/packages/shared/src/components/post/SharePostContent.tsx +++ b/packages/shared/src/components/post/SharePostContent.tsx @@ -8,6 +8,7 @@ import { getReadPostButtonText, isInternalReadType, isSharedPostSquadPost, + PostType, } from '../../graphql/posts'; import SettingsContext, { useSettingsContext, @@ -17,10 +18,13 @@ import { SharedLinkContainer } from './common/SharedLinkContainer'; import { SharedPostLink } from './common/SharedPostLink'; import { ButtonVariant } from '../buttons/Button'; import { ElementPlaceholder } from '../ElementPlaceholder'; -import { ProfileImageSize } from '../ProfilePicture'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; import { TruncateText } from '../utilities'; import { LazyImage } from '../LazyImage'; -import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; +import { + cloudinaryPostImageCoverPlaceholder, + cloudinarySquadsImageFallback, +} from '../../lib/image'; import { SharePostTitle } from './share/SharePostTitle'; import { BlockIcon, EarthIcon } from '../icons'; import { @@ -31,13 +35,74 @@ import { import { DeletedPostId } from '../../lib/constants'; import { IconSize } from '../Icon'; import { SourceType } from '../../graphql/sources'; +import { stripHtmlTags } from '../../lib/strings'; +import { Origin } from '../../lib/log'; +import { fallbackImages } from '../../lib/config'; export interface CommonSharePostContentProps { + post?: Post; sharedPost: SharedPost; source: Post['source']; onReadArticle: () => Promise; + isArticleModal?: boolean; } +const UNKNOWN_SOURCE_ID = 'unknown'; +const EMBEDDED_TWEET_AVATAR_FALLBACK = fallbackImages.avatar.replace( + 't_logo,', + '', +); +const isSquadPlaceholderAvatar = (image?: string): boolean => + !!image && + (image === cloudinarySquadsImageFallback || + image.includes('squad_placeholder')); + +const getPostText = ({ + content, + contentHtml, +}: { + content?: string; + contentHtml?: string; +}): string | undefined => { + const rawText = content || (contentHtml ? stripHtmlTags(contentHtml) : null); + const trimmedText = rawText?.trim(); + return trimmedText?.length ? trimmedText : undefined; +}; + +const formatHandleAsDisplayName = (handle: string): string => + handle + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); + +const removeHandlePrefixFromTitle = ({ + title, + sourceHandle, + authorHandle, +}: { + title?: string; + sourceHandle?: string; + authorHandle?: string; +}): string | undefined => { + if (!title) { + return title; + } + + const handlePrefixes = [sourceHandle, authorHandle] + .filter(Boolean) + .map((handle) => `@${handle}:`); + + const matchedPrefix = handlePrefixes.find((prefix) => + title.startsWith(prefix), + ); + if (matchedPrefix) { + return title.slice(matchedPrefix.length).trim(); + } + + return title.replace(/^@[A-Za-z0-9_]+:\s*/, '').trim(); +}; + const SharePostContentSkeleton = () => ( <> @@ -102,9 +167,11 @@ const PrivatePost = ({ ); export function CommonSharePostContent({ + post, sharedPost, source, onReadArticle, + isArticleModal, }: CommonSharePostContentProps): ReactElement { const { sidebarExpanded } = useSettingsContext(); const { openNewTab } = useContext(SettingsContext); @@ -132,6 +199,102 @@ export function CommonSharePostContent({ return ; } + const isSocialTwitter = sharedPost?.type === PostType.SocialTwitter; + const referenceHandle = + sharedPost?.source?.id === UNKNOWN_SOURCE_ID + ? sharedPost?.creatorTwitter || + post?.creatorTwitter || + sharedPost?.author?.username + : sharedPost?.source?.handle; + const repostingHandle = + post?.source?.id === UNKNOWN_SOURCE_ID + ? post?.creatorTwitter || post?.author?.username + : post?.source?.handle; + const shouldShowRepostingHandle = + isArticleModal && post?.subType === 'repost' && !!repostingHandle; + const quotedSourceName = sharedPost?.source?.name; + const isUnknownQuotedSourceName = + quotedSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; + const embeddedTweetName = + !isUnknownQuotedSourceName && quotedSourceName + ? quotedSourceName + : sharedPost?.author?.name; + const embeddedTweetDisplayName = + embeddedTweetName || + (referenceHandle && formatHandleAsDisplayName(referenceHandle)); + const embeddedTweetSourceAvatar = isSquadPlaceholderAvatar( + sharedPost?.source?.image, + ) + ? undefined + : sharedPost?.source?.image; + const embeddedTweetAvatar = + sharedPost?.author?.image || + embeddedTweetSourceAvatar || + EMBEDDED_TWEET_AVATAR_FALLBACK; + const embeddedTweetAvatarUser = { + id: + sharedPost?.author?.id || + sharedPost?.source?.id || + referenceHandle || + 'shared-post-avatar', + image: embeddedTweetAvatar, + username: referenceHandle, + name: embeddedTweetName, + }; + + if (isSocialTwitter) { + return ( + <> + {shouldShowRepostingHandle && ( +

+ @{repostingHandle} reposted +

+ )} + + +
+ {!!embeddedTweetAvatarUser && ( + + )} +
+ {!!embeddedTweetDisplayName && ( +

+ {embeddedTweetDisplayName} +

+ )} + {!!referenceHandle && ( +

+ @{referenceHandle} +

+ )} +
+
+

+ {sharedPost.title} +

+
+
+ + ); + } + return ( Promise; + origin?: Origin; } const SharePostContent = ({ post, onReadArticle, -}: SharePostContentProps): ReactElement => ( - <> - - - -); + origin, +}: SharePostContentProps): ReactElement => { + const isSocialTwitterContent = + post.type === PostType.SocialTwitter || + post.sharedPost?.type === PostType.SocialTwitter; + const sourceHandle = + post.source?.id === UNKNOWN_SOURCE_ID + ? post.creatorTwitter || post.author?.username + : post.source?.handle; + const shouldHideSocialTitle = + isSocialTwitterContent && + post.subType === 'repost' && + !getPostText({ content: post.content, contentHtml: post.contentHtml }); + const title = isSocialTwitterContent + ? removeHandlePrefixFromTitle({ + title: post.title, + sourceHandle, + authorHandle: post.author?.username, + }) + : post.title; + + return ( + <> + {!shouldHideSocialTitle && ( + + )} + + + ); +}; export default SharePostContent; diff --git a/packages/shared/src/components/post/SocialTwitterPostContent.tsx b/packages/shared/src/components/post/SocialTwitterPostContent.tsx index e5007b3851..e337cd25a8 100644 --- a/packages/shared/src/components/post/SocialTwitterPostContent.tsx +++ b/packages/shared/src/components/post/SocialTwitterPostContent.tsx @@ -9,7 +9,7 @@ import { isVideoPost, PostType, } from '../../graphql/posts'; -import SharePostContent from './SharePostContent'; +import SharePostContent, { CommonSharePostContent } from './SharePostContent'; import MarkdownPostContent from './MarkdownPostContent'; import { SquadPostWidgets } from './SquadPostWidgets'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -22,6 +22,7 @@ import { BoostNewPostStrip } from '../../features/boost/BoostNewPostStrip'; import { useActions, useViewSize, ViewSize } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import { useShowBoostButton } from '../../features/boost/useShowBoostButton'; +import { Origin } from '../../lib/log'; const ContentMap = { [PostType.Freeform]: MarkdownPostContent, @@ -84,6 +85,10 @@ function SocialTwitterPostContentRaw({ const finalType = isVideoPost(post) ? PostType.VideoYouTube : socialTwitterType || post?.type; + const shouldShowLinkedPreview = + finalType !== PostType.Share && + !!post.sharedPost && + post.sharedPost.type !== PostType.SocialTwitter; const Content = ContentMap[finalType] || MarkdownPostContent; return ( @@ -140,7 +145,26 @@ function SocialTwitterPostContentRaw({ className={shouldShowBanner && isLaptop ? 'mb-4' : 'mb-6'} /> {shouldShowBanner && isLaptop && } - + {finalType === PostType.Share ? ( + + ) : ( + <> + + {shouldShowLinkedPreview && ( + + )} + + )}
Date: Tue, 17 Feb 2026 16:40:43 +0200 Subject: [PATCH 03/34] feat(shared): align X repost metadata across cards and modal Unify social Twitter repost metadata presentation and sizing across grid/list cards and post modal so identity, iconography, and spacing remain consistent. Co-authored-by: Cursor --- .../components/cards/common/CardOverlay.tsx | 2 +- .../src/components/cards/common/common.tsx | 9 +- .../socialTwitter/SocialTwitterGrid.spec.tsx | 25 ++--- .../cards/socialTwitter/SocialTwitterGrid.tsx | 82 +++++++++++----- .../socialTwitter/SocialTwitterList.spec.tsx | 14 +-- .../cards/socialTwitter/SocialTwitterList.tsx | 94 ++++++++++++------- .../src/components/post/SharePostContent.tsx | 40 +++++--- .../src/components/utilities/DateFormat.tsx | 6 +- 8 files changed, 176 insertions(+), 96 deletions(-) diff --git a/packages/shared/src/components/cards/common/CardOverlay.tsx b/packages/shared/src/components/cards/common/CardOverlay.tsx index a1ecd2f15a..3b4b6966b2 100644 --- a/packages/shared/src/components/cards/common/CardOverlay.tsx +++ b/packages/shared/src/components/cards/common/CardOverlay.tsx @@ -27,7 +27,7 @@ const CardOverlay = ({ return ( ( - {separatorCharacter} + + {separatorCharacter} + ); export const visibleOnGroupHover = diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx index da54180889..e76b6ed8ab 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx @@ -81,10 +81,11 @@ it('should render top action link using post comments permalink', async () => { expect(link).toHaveAttribute('href', basePost.commentsPermalink); }); -it('should render source handle next to metadata date', async () => { +it('should render source name next to metadata date for regular tweets', async () => { renderComponent(); - expect((await screen.findAllByText(/@avengers/)).length).toBeGreaterThan(0); + expect(await screen.findByText(/Avengers/i)).toBeInTheDocument(); + expect(screen.queryByText(/Avengers reposted/i)).not.toBeInTheDocument(); }); it('should render thread content without duplicating title line', async () => { @@ -130,7 +131,9 @@ it('should render quote/repost detail from shared post', async () => { expect((await screen.findAllByText(/@devrelweekly/)).length).toBeGreaterThan( 0, ); - expect(await screen.findByText('DevRel Weekly')).toBeInTheDocument(); + expect( + await screen.findByText(/DevRel Weekly @devrelweekly/i), + ).toBeInTheDocument(); expect( await screen.findByAltText("devrelweekly's profile"), ).toBeInTheDocument(); @@ -170,7 +173,7 @@ it('should use creatorTwitter when shared source is unknown', async () => { expect(screen.queryByText('@unknown')).not.toBeInTheDocument(); }); -it('should use creatorTwitter when source is unknown for metadata handle', async () => { +it('should prefer source name when source id is unknown', async () => { renderComponent({ post: { ...basePost, @@ -183,9 +186,8 @@ it('should use creatorTwitter when source is unknown for metadata handle', async }, }); - expect((await screen.findAllByText(/@root_creator/)).length).toBeGreaterThan( - 0, - ); + expect(await screen.findByText('Avengers')).toBeInTheDocument(); + expect(screen.queryByText('@root_creator')).not.toBeInTheDocument(); expect(screen.queryByText('@unknown')).not.toBeInTheDocument(); }); @@ -217,11 +219,10 @@ it('should hide headline and tags for repost cards without repost text', async ( ), ).not.toBeInTheDocument(); expect(screen.queryByTestId('post-tags')).not.toBeInTheDocument(); - expect(await screen.findByText('@avengers')).toBeInTheDocument(); - expect(await screen.findByText('Y Combinator')).toBeInTheDocument(); - expect((await screen.findAllByText(/@ycombinator/)).length).toBeGreaterThan( - 0, - ); + expect(await screen.findByText(/Avengers reposted/i)).toBeInTheDocument(); + expect( + await screen.findByText(/Y Combinator @ycombinator/i), + ).toBeInTheDocument(); expect( await screen.findByText('Referenced tweet content'), ).toBeInTheDocument(); diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx index d401171d0b..88dd6571c6 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, Ref } from 'react'; +import type { ReactElement, ReactNode, Ref } from 'react'; import React, { forwardRef } from 'react'; import type { PostCardProps } from '../common/common'; import { @@ -190,6 +190,10 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( const embeddedTweetDisplayName = embeddedTweetName || (quotedHandle && formatHandleAsDisplayName(quotedHandle)); + const embeddedTweetIdentity = [embeddedTweetDisplayName, quotedHandle] + .filter(Boolean) + .map((value, index) => (index === 1 ? `@${value}` : value)) + .join(' '); const embeddedTweetSourceAvatar = isSquadPlaceholderAvatar( post.sharedPost?.source?.image, ) @@ -213,11 +217,21 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( post.source?.id === UNKNOWN_SOURCE_ID ? post.creatorTwitter || post.author?.username : post.source?.handle; + const sourceName = post.source?.name; + const isUnknownSourceName = sourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; + const repostedByName = + (!isUnknownSourceName && sourceName) || + post.author?.name || + (sourceHandle && formatHandleAsDisplayName(sourceHandle)); const title = removeHandlePrefixFromTitle({ title: rawTitle, sourceHandle, authorHandle: post.author?.username, }); + const cardOverlayLabel = + post.subType === 'repost' && repostedByName + ? `${repostedByName} reposted on X. ${title || post.title || ''}`.trim() + : title; const metadataHandles = post.subType === 'repost' ? [sourceHandle].filter(Boolean) @@ -225,6 +239,40 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( const embeddedTweetTextColorClass = post.read ? 'text-text-tertiary' : 'text-text-primary'; + let metadataContent: ReactNode; + if (post.subType === 'repost') { + metadataContent = ( + <> + {!!post.createdAt && } + + + {repostedByName} reposted + + + ); + } else if (metadataHandles.length === 1 && repostedByName) { + metadataContent = ( + <> + {!!post.createdAt && } + + + {repostedByName} + + + ); + } else { + metadataContent = metadataHandles.map((handle, index) => ( + + {(!!post.createdAt || index > 0) && }@{handle} + + )); + } const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -247,7 +295,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( post={post} onPostCardClick={onPostCardClick} onPostCardAuxClick={onPostCardAuxClick} - ariaLabel={title} + ariaLabel={cardOverlayLabel} /> @@ -290,12 +338,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( createdAt={post.createdAt} readTime={post.readTime} > - {metadataHandles.map((handle, index) => ( - - {(!!post.createdAt || index > 0) && }@{handle} - {post.subType === 'repost' && index === 0 ? ' reposted' : ''} - - ))} + {metadataContent} {threadBody && ( @@ -305,32 +348,19 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( )} {showQuoteDetail ? (
-
+
{!!embeddedTweetAvatarUser && ( )}
- {!!embeddedTweetDisplayName && ( -

- {embeddedTweetDisplayName} -

- )} - {!!quotedHandle && ( -

- @{quotedHandle} + {!!embeddedTweetIdentity && ( +

+ {embeddedTweetIdentity}

)}
diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx index e9871d7d36..c36b4cd960 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.spec.tsx @@ -78,8 +78,9 @@ it('should hide image and show referenced tweet block for shared social tweets', }, }); - expect(await screen.findByText('Referenced post')).toBeInTheDocument(); - expect(await screen.findByText('@devrelweekly')).toBeInTheDocument(); + expect( + await screen.findByText(/Referenced post @devrelweekly/i), + ).toBeInTheDocument(); expect( await screen.findByAltText("devrelweekly's profile"), ).toBeInTheDocument(); @@ -125,9 +126,8 @@ it('should hide headline and tags for repost cards without repost text', async ( ), ).not.toBeInTheDocument(); expect(screen.queryByTestId('post-tags')).not.toBeInTheDocument(); - expect(await screen.findByText('@avengers')).toBeInTheDocument(); - expect(await screen.findByText('Y Combinator')).toBeInTheDocument(); - expect((await screen.findAllByText(/@ycombinator/)).length).toBeGreaterThan( - 0, - ); + expect(await screen.findByText(/Avengers reposted/i)).toBeInTheDocument(); + expect( + await screen.findByText(/Y Combinator @ycombinator/i), + ).toBeInTheDocument(); }); diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx index fa8a514e71..133773723d 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -142,6 +142,13 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( post.source?.id === UNKNOWN_SOURCE_ID ? post.creatorTwitter || post.author?.username : post.source?.handle; + const repostSourceName = post.source?.name; + const isUnknownSourceName = + repostSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; + const repostedByName = + (!isUnknownSourceName && repostSourceName) || + post.author?.name || + (sourceHandle && formatHandleAsDisplayName(sourceHandle)); const metadataHandles = post.subType === 'repost' ? [sourceHandle].filter(Boolean) @@ -151,6 +158,12 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( sourceHandle, authorHandle: post.author?.username, }); + const cardLinkTitle = + post.subType === 'repost' && repostedByName + ? `${repostedByName} reposted on X. ${ + cleanedTitle || post.title || '' + }`.trim() + : cleanedTitle || post.title; const quotedSourceName = post.sharedPost?.source?.name; const isUnknownQuotedSourceName = quotedSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; @@ -161,6 +174,10 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( const embeddedTweetDisplayName = embeddedTweetName || (referenceHandle && formatHandleAsDisplayName(referenceHandle)); + const embeddedTweetIdentity = [embeddedTweetDisplayName, referenceHandle] + .filter(Boolean) + .map((value, index) => (index === 1 ? `@${value}` : value)) + .join(' '); const embeddedTweetSourceAvatar = isSquadPlaceholderAvatar( post.sharedPost?.source?.image, ) @@ -198,7 +215,6 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( /> ); - const metadata = useMemo(() => { const authorName = post?.author?.name; const sourceName = post?.source?.name; @@ -225,6 +241,40 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( post?.author?.name, post?.source?.name, ]); + let metadataBottomLabel = metadata.bottomLabel; + if (metadataHandles.length) { + if (post.subType === 'repost') { + metadataBottomLabel = ( + + + {repostedByName} reposted + + ); + } else if (metadataHandles.length === 1 && repostedByName) { + metadataBottomLabel = ( + + + {repostedByName} + + ); + } else { + metadataBottomLabel = ( + <> + {metadataHandles.map((handle, index) => ( + + {index > 0 && }@{handle} + + ))} + + ); + } + } return ( - {metadataHandles.map((handle, index) => ( - - {index > 0 && }@{handle} - {post.subType === 'repost' && index === 0 - ? ' reposted' - : ''} - - ))} - - ) : ( - metadata.bottomLabel - ), + bottomLabel: metadataBottomLabel, }} postLink={post.commentsPermalink} openNewTab={openNewTab} @@ -299,34 +336,19 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( )} {showReferenceTweet && (
-
+
{!!embeddedTweetAvatarUser && ( )}
- {!!embeddedTweetDisplayName && ( -

- {embeddedTweetDisplayName} -

- )} - {!!referenceHandle && ( -

- @{referenceHandle} + {!!embeddedTweetIdentity && ( +

+ {embeddedTweetIdentity}

)}
diff --git a/packages/shared/src/components/post/SharePostContent.tsx b/packages/shared/src/components/post/SharePostContent.tsx index 4fa0b181a2..302dda5d2e 100644 --- a/packages/shared/src/components/post/SharePostContent.tsx +++ b/packages/shared/src/components/post/SharePostContent.tsx @@ -26,7 +26,7 @@ import { cloudinarySquadsImageFallback, } from '../../lib/image'; import { SharePostTitle } from './share/SharePostTitle'; -import { BlockIcon, EarthIcon } from '../icons'; +import { BlockIcon, EarthIcon, TwitterIcon } from '../icons'; import { Typography, TypographyColor, @@ -210,8 +210,19 @@ export function CommonSharePostContent({ post?.source?.id === UNKNOWN_SOURCE_ID ? post?.creatorTwitter || post?.author?.username : post?.source?.handle; + const repostSourceName = post?.source?.name; + const isUnknownRepostSourceName = + repostSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; + const repostedByName = + (!isUnknownRepostSourceName && repostSourceName) || + post?.author?.name || + (repostingHandle && formatHandleAsDisplayName(repostingHandle)); const shouldShowRepostingHandle = isArticleModal && post?.subType === 'repost' && !!repostingHandle; + const repostIconClassName = isArticleModal + ? 'text-text-primary' + : 'relative top-px text-text-tertiary'; + const repostIconSize = isArticleModal ? IconSize.Size16 : IconSize.XXSmall; const quotedSourceName = sharedPost?.source?.name; const isUnknownQuotedSourceName = quotedSourceName?.toLowerCase() === UNKNOWN_SOURCE_ID; @@ -222,6 +233,10 @@ export function CommonSharePostContent({ const embeddedTweetDisplayName = embeddedTweetName || (referenceHandle && formatHandleAsDisplayName(referenceHandle)); + const embeddedTweetIdentity = [embeddedTweetDisplayName, referenceHandle] + .filter(Boolean) + .map((value, index) => (index === 1 ? `@${value}` : value)) + .join(' '); const embeddedTweetSourceAvatar = isSquadPlaceholderAvatar( sharedPost?.source?.image, ) @@ -247,7 +262,13 @@ export function CommonSharePostContent({ <> {shouldShowRepostingHandle && (

- @{repostingHandle} reposted + + + {repostedByName} reposted +

)} -
+
{!!embeddedTweetAvatarUser && ( )}
- {!!embeddedTweetDisplayName && ( -

- {embeddedTweetDisplayName} -

- )} - {!!referenceHandle && ( -

- @{referenceHandle} + {!!embeddedTweetIdentity && ( +

+ {embeddedTweetIdentity}

)}
diff --git a/packages/shared/src/components/utilities/DateFormat.tsx b/packages/shared/src/components/utilities/DateFormat.tsx index 59a874eac9..f8919d0616 100644 --- a/packages/shared/src/components/utilities/DateFormat.tsx +++ b/packages/shared/src/components/utilities/DateFormat.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import classNames from 'classnames'; import { format } from 'date-fns'; import type { TimeFormatType } from '../../lib/dateFormat'; import { formatDate } from '../../lib/dateFormat'; @@ -26,7 +27,10 @@ export const DateFormat = ({ return (