From 9124398993883deb7a7565a7f957feeb43773ce7 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 5 Mar 2026 18:42:39 +0200 Subject: [PATCH 1/5] fix(shared): improve X share previews and text direction handling Refine social/X share rendering across cards and write flow by reducing duplicate preview content, handling hydration safely, and applying language-aware RTL direction with auto fallback for tweet text. Made-with: Cursor --- .../src/components/FeedItemComponent.tsx | 6 +- .../socialTwitter/EmbeddedTweetPreview.tsx | 77 +++++--- .../socialTwitter/SocialTwitterGrid.spec.tsx | 15 +- .../cards/socialTwitter/SocialTwitterGrid.tsx | 110 +++++------- .../socialTwitter/SocialTwitterList.spec.tsx | 31 +--- .../cards/socialTwitter/SocialTwitterList.tsx | 136 +++++++------- .../socialTwitterHelpers.spec.tsx | 29 +++ .../socialTwitter/socialTwitterHelpers.tsx | 115 +++++------- .../src/components/post/SharePostContent.tsx | 40 +++++ .../post/SocialTwitterPostContent.tsx | 4 + .../src/components/post/write/ShareLink.tsx | 18 +- .../post/write/SubmitExternalLink.tsx | 10 +- .../post/write/WriteLinkPreview.tsx | 168 +++++++++++++----- packages/shared/src/graphql/posts.ts | 37 ++++ 14 files changed, 497 insertions(+), 299 deletions(-) diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 6723cd3998d..10e715d1aeb 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -6,7 +6,7 @@ import { PlaceholderGrid } from './cards/placeholder/PlaceholderGrid'; import { PlaceholderList } from './cards/placeholder/PlaceholderList'; import { SignalPlaceholderList } from './cards/placeholder/SignalPlaceholderList'; import type { Ad, Post, PostItem } from '../graphql/posts'; -import { PostType } from '../graphql/posts'; +import { isXShareLikePost, PostType } from '../graphql/posts'; import type { LoggedUser } from '../lib/user'; import useLogImpression from '../hooks/feed/useLogImpression'; import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick'; @@ -136,6 +136,10 @@ const getPostTypeForCard = (post?: Post): PostType => { return PostType.Article; } + if (isXShareLikePost(post)) { + return PostType.SocialTwitter; + } + return post.type; }; diff --git a/packages/shared/src/components/cards/socialTwitter/EmbeddedTweetPreview.tsx b/packages/shared/src/components/cards/socialTwitter/EmbeddedTweetPreview.tsx index 3bd92d7f367..771f7e52116 100644 --- a/packages/shared/src/components/cards/socialTwitter/EmbeddedTweetPreview.tsx +++ b/packages/shared/src/components/cards/socialTwitter/EmbeddedTweetPreview.tsx @@ -6,6 +6,8 @@ import type { UserImageProps } from '../../ProfilePicture'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { IconSize } from '../../Icon'; import { TwitterIcon } from '../../icons'; +import { Image } from '../../image/Image'; +import { getSocialTextDirectionProps } from './socialTwitterHelpers'; interface EmbeddedTweetPreviewProps { post: Post; @@ -15,8 +17,25 @@ interface EmbeddedTweetPreviewProps { textClampClass: string; bodyClassName?: string; showXLogo?: boolean; + showMedia?: boolean; } +const isLikelyTweetMediaUrl = (url?: string): boolean => { + if (!url) { + return false; + } + + const normalized = url.toLowerCase(); + if ( + normalized.includes('/profile_images/') || + normalized.includes('/profile_banners/') + ) { + return false; + } + + return true; +}; + export function EmbeddedTweetPreview({ post, embeddedTweetAvatarUser, @@ -25,18 +44,23 @@ export function EmbeddedTweetPreview({ textClampClass, bodyClassName, showXLogo = false, + showMedia = false, }: EmbeddedTweetPreviewProps): ReactElement { const resolvedBodyClassName = bodyClassName ?? 'typo-callout'; + const mediaSrc = post.sharedPost?.image || post.image; + const shouldShowMedia = showMedia && isLikelyTweetMediaUrl(mediaSrc); + const tweetLanguage = post.sharedPost?.language || post.language; + const tweetTextDirectionProps = getSocialTextDirectionProps(tweetLanguage); return (
-
-
+
+
-
+
{!!embeddedTweetIdentity && (

@@ -59,32 +84,30 @@ export function EmbeddedTweetPreview({

{showXLogo && ( )}
- {post.sharedPost?.titleHtml ? ( -

- ) : ( -

- {post.sharedPost?.title} -

+

+ {post.sharedPost?.title || post.title} +

+ {shouldShowMedia && !!mediaSrc && ( +
+ Tweet media +
)}
); diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx index d10d85bbc11..741c31c4b8c 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.spec.tsx @@ -81,10 +81,10 @@ it('should render top action link using post comments permalink', async () => { expect(link).toHaveAttribute('href', basePost.permalink); }); -it('should render source name next to metadata date for regular tweets', async () => { +it('should render "Posted on X" next to metadata date for regular tweets', async () => { renderComponent(); - expect(await screen.findByText(/Avengers/i)).toBeInTheDocument(); + expect(await screen.findByText(/Posted on/i)).toBeInTheDocument(); expect(screen.queryByText(/Avengers reposted/i)).not.toBeInTheDocument(); }); @@ -127,7 +127,7 @@ it('should render quote/repost detail from shared post', async () => { }, }); - expect(await screen.findByText(/Avengers reposted/i)).toBeInTheDocument(); + expect(await screen.findByText(/Posted on/i)).toBeInTheDocument(); expect((await screen.findAllByText(/@devrelweekly/)).length).toBeGreaterThan( 0, ); @@ -173,7 +173,7 @@ it('should use creatorTwitter when shared source is unknown', async () => { expect(screen.queryByText('@unknown')).not.toBeInTheDocument(); }); -it('should prefer source name when source id is unknown', async () => { +it('should use creator identity when source id is unknown', async () => { renderComponent({ post: { ...basePost, @@ -186,10 +186,7 @@ it('should prefer source name when source id is unknown', async () => { }, }); - expect( - await screen.findByText('Lee Hansel Solevilla Jr'), - ).toBeInTheDocument(); - expect(screen.queryByText('@root_creator')).not.toBeInTheDocument(); + expect(await screen.findByText('root_creator')).toBeInTheDocument(); expect(screen.queryByText('@unknown')).not.toBeInTheDocument(); }); @@ -221,7 +218,7 @@ 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 reposted/i)).toBeInTheDocument(); + expect(await screen.findByText(/Posted on/i)).toBeInTheDocument(); expect( await screen.findByText(/Y Combinator @ycombinator/i), ).toBeInTheDocument(); diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx index 6d20959c167..df04a558331 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx @@ -9,7 +9,6 @@ import { import FeedItemContainer from '../common/FeedItemContainer'; import { CardHeader, - CardImage, CardTextContainer, CardTitle, getPostClassNames, @@ -27,11 +26,11 @@ import { ButtonVariant } from '../../buttons/Button'; import { IconSize } from '../../Icon'; import { TwitterIcon } from '../../icons'; import { useFeedPreviewMode } from '../../../hooks'; -import { isSocialTwitterShareLike } from '../../../graphql/posts'; import { isSourceUserSource } from '../../../graphql/sources'; import { sanitizeMessage } from '../../../features/onboarding/shared'; import { getSocialTwitterMetadata, + getSocialTextDirectionProps, getSocialTwitterMetadataLabel, } from './socialTwitterHelpers'; import { EmbeddedTweetPreview } from './EmbeddedTweetPreview'; @@ -91,45 +90,39 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( ): ReactElement { const isFeedPreview = useFeedPreviewMode(); const isUserSource = isSourceUserSource(post.source); - const isQuoteLike = isSocialTwitterShareLike(post); - const shouldHideMedia = post.subType === 'thread'; - const showQuoteDetail = isQuoteLike; - const showMediaDetail = !isQuoteLike && !shouldHideMedia && !!post.image; - const shouldHideRepostHeadlineAndTags = - post.subType === 'repost' && !post.content?.trim(); - const quoteDetailsContainerClass = shouldHideRepostHeadlineAndTags - ? 'mx-1 mb-1 mt-2 min-h-[13.5rem] flex-1' - : 'mx-1 mb-1 mt-2 h-40'; - const quoteDetailsTextClampClass = shouldHideRepostHeadlineAndTags - ? 'line-clamp-[10]' - : 'line-clamp-5'; const rawTitle = post.title || post.sharedPost?.title; + const normalizedContent = ( + post.content || (post.contentHtml ? sanitizeMessage(post.contentHtml, []) : '') + ).trim(); + const repostPrefixPattern = /^.*?reposted on x\.\s*/i; + const titleWithoutRepostPrefix = + rawTitle?.replace(repostPrefixPattern, '').trim() ?? ''; + const sharedTitle = post.sharedPost?.title?.trim() ?? ''; + const hasTitleCommentary = + post.subType !== 'repost' && + !!titleWithoutRepostPrefix && + !!sharedTitle && + !sharedTitle.startsWith(titleWithoutRepostPrefix); + const hasDailyDevMarkdown = !!normalizedContent || hasTitleCommentary; + const quoteDetailsContainerClass = 'mx-1 mb-1 mt-2'; + const quoteDetailsTextClampClass = hasDailyDevMarkdown + ? 'line-clamp-6' + : 'line-clamp-8'; const cardTags = post.tags?.length ? post.tags : post.sharedPost?.tags; - const threadBody = - post.subType === 'thread' + const commentaryBody = hasDailyDevMarkdown + ? post.subType === 'thread' ? normalizeThreadBody({ title: rawTitle, content: post.content, contentHtml: post.contentHtml, }) - : undefined; - const { - repostedByName, - metadataHandles, - embeddedTweetIdentity, - embeddedTweetAvatarUser, - } = getSocialTwitterMetadata(post); - const cardOverlayLabel = - isQuoteLike && repostedByName - ? `${repostedByName} reposted on X. ${ - rawTitle || post.title || '' - }`.trim() - : rawTitle; - const metadataLabel = getSocialTwitterMetadataLabel({ - isRepostLike: isQuoteLike, - repostedByName, - metadataHandles, - }); + : normalizedContent || undefined + : undefined; + const { embeddedTweetIdentity, embeddedTweetAvatarUser } = + getSocialTwitterMetadata(post); + const socialTextDirectionProps = getSocialTextDirectionProps(post.language); + const cardOverlayLabel = rawTitle; + const metadataLabel = getSocialTwitterMetadataLabel(); const metadataContent = ( <> {!!post.createdAt && } @@ -147,7 +140,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( className: getPostClassNames( post, domProps.className, - 'min-h-card max-h-card', + 'min-h-card max-h-card overflow-hidden', ), }} ref={ref} @@ -187,14 +180,17 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid(
- {!shouldHideRepostHeadlineAndTags && ( - + {hasDailyDevMarkdown && ( + {rawTitle} )} - {!shouldHideRepostHeadlineAndTags &&
} - {!shouldHideRepostHeadlineAndTags && !!cardTags?.length && ( + {hasDailyDevMarkdown && !!cardTags?.length && ( )} - {threadBody && ( -

- {threadBody} + {commentaryBody && ( +

+ {commentaryBody}

)} - {showQuoteDetail ? ( - - ) : null} - {showMediaDetail && ( -
- -
- )} + = {}) => , ); -it('should hide image and show referenced tweet block for shared social tweets', async () => { +it('should show referenced tweet block for shared social tweets', async () => { renderComponent({ post: { ...basePost, @@ -88,31 +88,14 @@ it('should hide image and show referenced tweet block for shared social tweets', expect(screen.queryByAltText('Post cover image')).not.toBeInTheDocument(); }); -it('should render image for non-shared social tweets', async () => { +it('should render embedded tweet preview 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 not render source handle in metadata bottom label', async () => { - renderComponent({ - post: { - ...basePost, - title: 'Root tweet title without handle', - source: { - ...basePost.source, - handle: 'uniquesourcehandle', - }, - }, - }); - - expect( - await screen.findByRole('link', { name: 'Read on' }), - ).toBeInTheDocument(); - expect(screen.queryByText('@uniquesourcehandle')).not.toBeInTheDocument(); + expect(await screen.findByText('@dailydotdev: Root tweet')).toBeInTheDocument(); + expect(screen.queryByAltText('Post cover image')).not.toBeInTheDocument(); }); it('should hide headline and tags for repost cards without repost text', async () => { @@ -144,11 +127,7 @@ 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.findByTitle( - /Avengers reposted on X\. @bcherny: RT @ycombinator:/i, - ), - ).toBeInTheDocument(); + expect(await screen.findByText(/Posted on/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 c5b41c2faaf..94fadb077ba 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -9,24 +9,25 @@ import { useViewSize, ViewSize, } from '../../../hooks'; -import { usePostImage } from '../../../hooks/post/usePostImage'; import FeedItemContainer from '../common/list/FeedItemContainer'; import { CardContainer, CardContent, CardTitle } from '../common/list/ListCard'; import { PostCardHeader } from '../common/list/PostCardHeader'; -import { CardCoverList } from '../common/list/CardCover'; import ActionButtons from '../common/ActionButtons'; -import { HIGH_PRIORITY_IMAGE_PROPS } from '../../image/Image'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { sanitizeMessage } from '../../../features/onboarding/shared'; import { isSourceUserSource } from '../../../graphql/sources'; -import { isSocialTwitterShareLike, PostType } from '../../../graphql/posts'; +import { isXShareLikePost } from '../../../graphql/posts'; import PostTags from '../common/PostTags'; import SourceButton from '../common/SourceButton'; import { ProfileImageSize } from '../../ProfilePicture'; import { IconSize } from '../../Icon'; import { TwitterIcon } from '../../icons'; -import { getSocialTwitterMetadata } from './socialTwitterHelpers'; +import { + getSocialTwitterMetadata, + getSocialTextDirectionProps, + getSocialTwitterMetadataLabel, +} from './socialTwitterHelpers'; import { EmbeddedTweetPreview } from './EmbeddedTweetPreview'; export const SocialTwitterList = forwardRef(function SocialTwitterList( @@ -38,11 +39,9 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( onCommentClick, onCopyLinkClick, onBookmarkClick, - onShare, children, enableSourceHeader = false, domProps = {}, - eagerLoadImage = false, }: PostCardProps, ref: Ref, ): ReactElement { @@ -51,7 +50,6 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( const onPostCardClick = () => onPostClick(post); const containerRef = useRef(); const isFeedPreview = useFeedPreviewMode(); - const image = usePostImage(post); const { title } = useSmartTitle(post); const content = useMemo( () => (post.contentHtml ? sanitizeMessage(post.contentHtml, []) : ''), @@ -59,19 +57,30 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( ); const { title: truncatedTitle } = useTruncatedSummary(title, content); const isUserSource = isSourceUserSource(post.source); - const isQuoteLike = isSocialTwitterShareLike(post); + const isRepostLike = isXShareLikePost(post); const postForTags = post.tags?.length ? post : post.sharedPost || post; - const showReferenceTweet = post.sharedPost?.type === PostType.SocialTwitter; - const showMediaCover = !!image && !showReferenceTweet; - const shouldHideRepostHeadlineAndTags = - post.subType === 'repost' && !post.content?.trim(); - const quoteDetailsTextClampClass = shouldHideRepostHeadlineAndTags + const repostPrefixPattern = /^.*?reposted on x\.\s*/i; + const titleWithoutRepostPrefix = + post.title?.replace(repostPrefixPattern, '').trim() ?? ''; + const sharedTitle = post.sharedPost?.title?.trim() ?? ''; + const hasTitleCommentary = + post.subType !== 'repost' && + !!titleWithoutRepostPrefix && + !!sharedTitle && + !sharedTitle.startsWith(titleWithoutRepostPrefix); + const normalizedContent = (post.content || content).trim(); + const hasDailyDevMarkdown = !!normalizedContent || hasTitleCommentary; + const quoteDetailsTextClampClass = hasDailyDevMarkdown ? 'line-clamp-8' - : 'line-clamp-4'; - const { repostedByName, embeddedTweetIdentity, embeddedTweetAvatarUser } = - getSocialTwitterMetadata(post); + : 'line-clamp-10'; + const { + repostedByName, + embeddedTweetIdentity, + embeddedTweetAvatarUser, + } = getSocialTwitterMetadata(post); + const socialTextDirectionProps = getSocialTextDirectionProps(post.language); const cardLinkTitle = - isQuoteLike && repostedByName + isRepostLike && repostedByName ? `${repostedByName} reposted on X. ${ truncatedTitle || post.title || '' }`.trim() @@ -91,16 +100,34 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( />
); - const authorName = post?.author?.name; - const sourceName = post?.source?.name; - const metadata = isUserSource - ? { + const metadata = useMemo(() => { + const authorName = post?.author?.name; + const sourceName = post?.source?.name; + + if (isUserSource) { + return { topLabel: authorName || sourceName, - } - : { + }; + } + + if (enableSourceHeader) { + return { topLabel: sourceName || authorName, - ...(enableSourceHeader ? { bottomLabel: authorName } : {}), + bottomLabel: authorName, }; + } + + return { + topLabel: sourceName || authorName, + }; + }, [ + enableSourceHeader, + isUserSource, + post?.author?.name, + post?.source?.name, + ]); + const metadataBottomLabel = getSocialTwitterMetadataLabel(); + return ( -
- {!shouldHideRepostHeadlineAndTags && ( - +
+ {hasDailyDevMarkdown && ( + {truncatedTitle} )} + {hasDailyDevMarkdown && !!normalizedContent && ( +

+ {normalizedContent} +

+ )}
- {!shouldHideRepostHeadlineAndTags && ( + {hasDailyDevMarkdown && (
{post.clickbaitTitleDetected && }
)} - {showReferenceTweet && ( - - )} -
- {!isMobile && actionButtons} -
- - {showMediaCover && ( - - )} + {!isMobile && actionButtons} +
{isMobile && actionButtons} diff --git a/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.spec.tsx b/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.spec.tsx index 6872262b2c4..95d6e103dcf 100644 --- a/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.spec.tsx +++ b/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.spec.tsx @@ -5,6 +5,8 @@ import { PostType } from '../../../graphql/posts'; import { sharePost } from '../../../../__tests__/fixture/post'; import { getSocialTwitterMetadata, + getSocialTextDirectionProps, + getSocialTextDirection, getSocialTwitterMetadataLabel, } from './socialTwitterHelpers'; @@ -71,3 +73,30 @@ describe('getSocialTwitterMetadata', () => { expect(screen.getByTestId('twitter-icon')).toBeInTheDocument(); }); }); + +describe('getSocialTextDirection', () => { + it('returns rtl for known rtl languages', () => { + expect(getSocialTextDirection('he')).toBe('rtl'); + expect(getSocialTextDirection('ar-SA')).toBe('rtl'); + expect(getSocialTextDirection('fa_IR')).toBe('rtl'); + }); + + it('returns auto for ltr and unknown languages', () => { + expect(getSocialTextDirection('en')).toBe('auto'); + expect(getSocialTextDirection('ja')).toBe('auto'); + expect(getSocialTextDirection(undefined)).toBe('auto'); + }); +}); + +describe('getSocialTextDirectionProps', () => { + it('returns normalized lang with rtl direction when needed', () => { + expect(getSocialTextDirectionProps('HE-IL')).toEqual({ + dir: 'rtl', + lang: 'he-il', + }); + }); + + it('returns auto direction without lang when language is empty', () => { + expect(getSocialTextDirectionProps('')).toEqual({ dir: 'auto' }); + }); +}); diff --git a/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.tsx b/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.tsx index b74bc111f53..eab356fece6 100644 --- a/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.tsx +++ b/packages/shared/src/components/cards/socialTwitter/socialTwitterHelpers.tsx @@ -3,9 +3,46 @@ import React from 'react'; import type { Post } from '../../../graphql/posts'; import { UNKNOWN_SOURCE_ID } from '../../../lib/utils'; import { fallbackImages } from '../../../lib/config'; -import { IconSize } from '../../Icon'; -import { TwitterIcon } from '../../icons'; -import { Separator } from '../common/common'; + +const rtlLanguageCodes = new Set([ + 'ar', + 'dv', + 'fa', + 'he', + 'ku', + 'ps', + 'sd', + 'ug', + 'ur', + 'yi', +]); + +const getLanguagePrimarySubtag = (language?: string): string | undefined => + language + ?.toLowerCase() + .split(/[-_]/)[0] + .trim(); + +export const getSocialTextDirection = ( + language?: string, +): 'rtl' | 'auto' => { + const primarySubtag = getLanguagePrimarySubtag(language); + if (!primarySubtag) { + return 'auto'; + } + + return rtlLanguageCodes.has(primarySubtag) ? 'rtl' : 'auto'; +}; + +export const getSocialTextDirectionProps = ( + language?: string, +): { dir: 'rtl' | 'auto'; lang?: string } => { + const normalizedLanguage = language?.trim().toLowerCase(); + return { + dir: getSocialTextDirection(normalizedLanguage), + ...(normalizedLanguage && { lang: normalizedLanguage }), + }; +}; /** * Resolve a field based on whether the source is the unknown placeholder. @@ -19,21 +56,6 @@ const resolveBySource = ( ): T | undefined => sourceId === UNKNOWN_SOURCE_ID ? creatorValue : sourceValue || creatorValue; -const getUniqueHandles = (handles: (string | undefined)[]): string[] => { - const uniqueHandles = new Map(); - - handles.filter(Boolean).forEach((handle) => { - const normalizedHandle = handle.toLowerCase(); - if (uniqueHandles.has(normalizedHandle)) { - return; - } - - uniqueHandles.set(normalizedHandle, handle); - }); - - return Array.from(uniqueHandles.values()); -}; - export const getSocialTwitterMetadata = (post: Post) => { const sourceHandle = resolveBySource( post.source?.id, @@ -53,7 +75,10 @@ export const getSocialTwitterMetadata = (post: Post) => { post.author?.name || post.creatorTwitterName, ); - const metadataHandles = getUniqueHandles([sourceHandle, sharedPostHandle]); + const metadataHandles = + post.subType === 'repost' + ? [sourceHandle].filter(Boolean) + : [...new Set([sourceHandle, sharedPostHandle].filter(Boolean))]; const embeddedTweetDisplayName = resolveBySource( post.sharedPost?.source?.id, @@ -95,50 +120,8 @@ export const getSocialTwitterMetadata = (post: Post) => { }; }; -export const getSocialTwitterMetadataLabel = ({ - isRepostLike, - repostedByName, - metadataHandles, -}: { - isRepostLike?: boolean; - repostedByName?: string | false; - metadataHandles: string[]; -}): ReactElement => { - const twitterIcon = ( - - ); - - if (isRepostLike && repostedByName) { - return ( - - {repostedByName} reposted - {twitterIcon} - - ); - } - - if (metadataHandles.length === 1 && repostedByName) { - return ( - - {repostedByName} - {twitterIcon} - - ); - } - - return ( - - - {metadataHandles.map((handle, index) => ( - - {index > 0 && }@{handle} - - ))} - - {twitterIcon} - - ); -}; +export const getSocialTwitterMetadataLabel = (): ReactElement => ( + + Posted on x.com + +); diff --git a/packages/shared/src/components/post/SharePostContent.tsx b/packages/shared/src/components/post/SharePostContent.tsx index 3b9885f372c..7313dfe1bb4 100644 --- a/packages/shared/src/components/post/SharePostContent.tsx +++ b/packages/shared/src/components/post/SharePostContent.tsx @@ -7,6 +7,7 @@ import type { Post, SharedPost } from '../../graphql/posts'; import { getReadPostButtonText, isInternalReadType, + isSocialTwitterPost, isSharedPostSquadPost, } from '../../graphql/posts'; import SettingsContext, { @@ -31,6 +32,8 @@ import { import { DeletedPostId } from '../../lib/constants'; import { IconSize } from '../Icon'; import { SourceType } from '../../graphql/sources'; +import { EmbeddedTweetPreview } from '../cards/socialTwitter/EmbeddedTweetPreview'; +import { getSocialTwitterMetadata } from '../cards/socialTwitter/socialTwitterHelpers'; export interface CommonSharePostContentProps { sharedPost: SharedPost; @@ -128,6 +131,14 @@ export function CommonSharePostContent({ const { private: isPrivate, source: sharedPostSource } = sharedPost; const { type } = sharedPostSource; const sharedContainerClassName = isCompactSpacing ? 'mb-4 mt-6' : 'mb-5 mt-8'; + const xTitleMatch = sharedPost.title?.match(/^(.*?)\s+\(@([^)]+)\):\s*(.+)$/s); + const hasXDomain = [ + sharedPost.permalink, + sharedPost.commentsPermalink, + sharedPost.domain, + ].some((value) => /(?:x\.com|twitter\.com|t\.co)/i.test(value ?? '')); + const shouldRenderTweetPreview = + isSocialTwitterPost(sharedPost) || hasXDomain || !!xTitleMatch; if (isDeleted) { return ; @@ -143,6 +154,35 @@ export function CommonSharePostContent({ ); } + if (shouldRenderTweetPreview) { + const tweetBody = xTitleMatch?.[3]?.trim() || sharedPost.title; + const socialTwitterPostPreview: Post = { + ...sharedPost, + sharedPost: { + ...sharedPost, + title: tweetBody, + }, + }; + const { embeddedTweetIdentity, embeddedTweetAvatarUser } = + getSocialTwitterMetadata(socialTwitterPostPreview); + const parsedIdentity = xTitleMatch + ? `${xTitleMatch[1].trim()} @${xTitleMatch[2].trim()}` + : undefined; + + return ( + + ); + } + return ( {post.titleHtml ? (

) : (

diff --git a/packages/shared/src/components/post/write/ShareLink.tsx b/packages/shared/src/components/post/write/ShareLink.tsx index 2cff1ba0bb7..cc3d4e46e80 100644 --- a/packages/shared/src/components/post/write/ShareLink.tsx +++ b/packages/shared/src/components/post/write/ShareLink.tsx @@ -1,5 +1,5 @@ import type { FormEventHandler, ReactElement } from 'react'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import z from 'zod'; import classNames from 'classnames'; import { useRouter } from 'next/router'; @@ -67,6 +67,22 @@ export function ShareLink({ return initialCommentary ?? ''; }); + + useEffect(() => { + if (!fetchedPost?.sharedPost) { + return; + } + + if (fetchedPost.title !== fetchedPost.sharedPost.title) { + return; + } + + if (commentary !== fetchedPost.sharedPost.title) { + return; + } + + setCommentary(''); + }, [commentary, fetchedPost]); const { getLinkPreview, isLoadingPreview, diff --git a/packages/shared/src/components/post/write/SubmitExternalLink.tsx b/packages/shared/src/components/post/write/SubmitExternalLink.tsx index f67b9a37d42..795e4ba620e 100644 --- a/packages/shared/src/components/post/write/SubmitExternalLink.tsx +++ b/packages/shared/src/components/post/write/SubmitExternalLink.tsx @@ -1,5 +1,5 @@ import type { FormEventHandler, ReactElement } from 'react'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import type { ExternalLinkPreview, @@ -33,6 +33,10 @@ export function SubmitExternalLink({ }: SubmitExternalLinkProps): ReactElement { const isMobile = useViewSize(ViewSize.MobileL); const { openModal } = useLazyModal(); + const [isHydrated, setIsHydrated] = useState(false); + useEffect(() => { + setIsHydrated(true); + }, []); const [url, setUrl] = useState(() => { if (initialUrl && isValidHttpUrl(initialUrl)) { getLinkPreview(initialUrl); @@ -58,6 +62,10 @@ export function SubmitExternalLink({ return ; } + if (preview && !isHydrated) { + return ; + } + if (preview) { return ( diff --git a/packages/shared/src/components/post/write/WriteLinkPreview.tsx b/packages/shared/src/components/post/write/WriteLinkPreview.tsx index af83b52e3ed..3d9f2457b96 100644 --- a/packages/shared/src/components/post/write/WriteLinkPreview.tsx +++ b/packages/shared/src/components/post/write/WriteLinkPreview.tsx @@ -1,5 +1,5 @@ import type { FormEventHandler, ReactElement } from 'react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { TextField } from '../../fields/TextField'; import { LinkIcon, OpenLinkIcon } from '../../icons'; @@ -10,7 +10,8 @@ import { WritePreviewContainer, WritePreviewContent, } from './common'; -import type { ExternalLinkPreview } from '../../../graphql/posts'; +import type { ExternalLinkPreview, Post } from '../../../graphql/posts'; +import { PostType } from '../../../graphql/posts'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { @@ -24,6 +25,8 @@ import { webappUrl } from '../../../lib/constants'; import { TimeFormatType } from '../../../lib/dateFormat'; import { DateFormat } from '../../utilities'; import { Separator } from '../../cards/common/common'; +import { EmbeddedTweetPreview } from '../../cards/socialTwitter/EmbeddedTweetPreview'; +import { getSocialTwitterMetadata } from '../../cards/socialTwitter/socialTwitterHelpers'; interface WriteLinkPreviewProps { link: string; @@ -44,8 +47,13 @@ export function WriteLinkPreview({ showPreviewLink = true, variant = 'default', }: WriteLinkPreviewProps): ReactElement { - return ( - <> + const [isHydrated, setIsHydrated] = useState(false); + useEffect(() => { + setIsHydrated(true); + }, []); + + if (!isHydrated) { + return ( {showPreviewLink && ( )} - {preview.title && ( - -
- {preview.title} - {preview.source?.id !== 'unknown' && - (isMinimized ? ( - + ); + } + + const xTitleMatch = preview.title?.match(/^(.*?)\s+\(@([^)]+)\):\s*(.+)$/s); + const isXPreview = [ + preview.finalUrl, + preview.url, + preview.permalink, + link, + preview.title, + ].some((value) => /(?:x\.com|twitter\.com|t\.co)/i.test(value ?? '')); + const shouldUseXPreview = !!preview.title && (isXPreview || !!xTitleMatch); + const xPreviewBody = xTitleMatch?.[3]?.trim() || preview.title; + const xPreviewPost = shouldUseXPreview + ? ({ + ...(preview as unknown as Post), + type: PostType.SocialTwitter, + sharedPost: { + ...(preview as unknown as Post), + type: PostType.SocialTwitter, + title: xPreviewBody, + image: preview.image, + source: preview.source, + }, + } as Post) + : null; + const { embeddedTweetIdentity, embeddedTweetAvatarUser } = xPreviewPost + ? getSocialTwitterMetadata(xPreviewPost) + : { embeddedTweetIdentity: '', embeddedTweetAvatarUser: null }; + const parsedIdentity = xTitleMatch + ? `${xTitleMatch[1].trim()} @${xTitleMatch[2].trim()}` + : undefined; + const shouldRenderWritePreviewContainer = + showPreviewLink || (!!preview.title && (!xPreviewPost || !embeddedTweetAvatarUser)); + + return ( + <> + {preview.title && xPreviewPost && embeddedTweetAvatarUser && ( + + )} + {shouldRenderWritePreviewContainer && ( + + {showPreviewLink && ( + } + label="URL" + type="url" + name="url" + inputId="preview_url" + fieldType="tertiary" + className={{ container: 'w-full' }} + value={link} + onInput={onLinkChange} + /> + )} + {preview.title && + (!xPreviewPost || !embeddedTweetAvatarUser) && ( + +
+ {preview.title} + {preview.source?.id !== 'unknown' && + (isMinimized ? ( + + ) : ( + + + + {preview.source?.name} + + + ))} +
+ {preview.image && ( + {`${preview.title}`} - ) : ( - - - - {preview.source?.name} - - - ))} -
- {preview.image && ( - {`${preview.title}`} - )} - {!isMinimized && ( -

+ {preview.image && ( + {`${preview.title}`} + )} + {!isMinimized && ( +