diff --git a/packages/shared/__tests__/fixture/tweetPost.ts b/packages/shared/__tests__/fixture/tweetPost.ts new file mode 100644 index 0000000000..e414d04a63 --- /dev/null +++ b/packages/shared/__tests__/fixture/tweetPost.ts @@ -0,0 +1,118 @@ +import type { Post, TweetMedia } from '../../src/graphql/posts'; +import { PostType } from '../../src/graphql/posts'; + +export const tweetMedia: TweetMedia[] = [ + { + type: 'image', + url: 'https://pbs.twimg.com/media/example1.jpg', + width: 1200, + height: 800, + }, +]; + +export const tweetPost: Post = { + id: 'tweet-test-123', + title: 'Check out our latest feature release!', + permalink: 'https://x.com/dailydotdev/status/1234567890123456789', + createdAt: '2026-01-13T10:00:00.000Z', + source: { + id: 'twitter', + handle: 'twitter', + name: 'Twitter', + image: 'https://abs.twimg.com/icons/apple-touch-icon-192x192.png', + }, + image: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/tweet-placeholder', + commentsPermalink: 'https://daily.dev/posts/tweet-test-123', + type: PostType.Tweet, + tweetId: '1234567890123456789', + tweetAuthorUsername: 'dailydotdev', + tweetAuthorName: 'daily.dev', + tweetAuthorAvatar: + 'https://pbs.twimg.com/profile_images/1234567890/avatar_normal.jpg', + tweetAuthorVerified: true, + tweetContent: + 'Check out our latest feature release! ๐Ÿš€ We have been working hard on this for months.', + tweetContentHtml: + 'Check out our latest feature release! ๐Ÿš€ We have been working hard on this for months.', + tweetCreatedAt: new Date('2026-01-13T10:00:00Z'), + tweetLikeCount: 1234, + tweetRetweetCount: 567, + tweetReplyCount: 89, + tweetMedia: [], + isThread: false, + tweetStatus: 'available', + tags: ['opensource', 'devtools'], +}; + +export const tweetPostWithMedia: Post = { + ...tweetPost, + id: 'tweet-test-with-media', + tweetMedia, +}; + +export const tweetPostThread: Post = { + ...tweetPost, + id: 'tweet-test-thread', + isThread: true, + threadSize: 3, + threadTweets: [ + { + tweetId: '1234567890123456790', + content: 'This is the second tweet in the thread.', + contentHtml: 'This is the second tweet in the thread.', + createdAt: new Date('2026-01-13T10:01:00Z'), + media: [], + likeCount: 234, + retweetCount: 45, + replyCount: 12, + }, + { + tweetId: '1234567890123456791', + content: 'Third and final tweet.', + contentHtml: 'Third and final tweet.', + createdAt: new Date('2026-01-13T10:02:00Z'), + media: tweetMedia, + likeCount: 189, + retweetCount: 34, + replyCount: 8, + }, + ], +}; + +export const tweetPostProcessing: Post = { + ...tweetPost, + id: 'tweet-test-processing', + tweetStatus: 'processing', + tweetContent: undefined, + tweetContentHtml: undefined, +}; + +export const tweetPostDeleted: Post = { + ...tweetPost, + id: 'tweet-test-deleted', + tweetStatus: 'deleted', +}; + +export const tweetPostPrivate: Post = { + ...tweetPost, + id: 'tweet-test-private', + tweetStatus: 'private', +}; + +export const tweetPostFailed: Post = { + ...tweetPost, + id: 'tweet-test-failed', + tweetStatus: 'failed', + tweetErrorReason: 'Rate limited. Please try again later.', +}; + +export const tweetPostUnverified: Post = { + ...tweetPost, + id: 'tweet-test-unverified', + tweetAuthorUsername: 'developer123', + tweetAuthorName: 'Developer', + tweetAuthorVerified: false, +}; + +export default tweetPost; diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index f3c2c4de8e..8e7bcf7e8c 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -122,6 +122,11 @@ const PollPostModal = dynamic( import(/* webpackChunkName: "pollPostModal" */ './modals/PollPostModal'), ); +const TweetPostModal = dynamic( + () => + import(/* webpackChunkName: "tweetPostModal" */ './modals/TweetPostModal'), +); + const BriefCardFeed = dynamic( () => import( @@ -143,6 +148,7 @@ export const PostModalMap: Record = { [PostType.Collection]: CollectionPostModal, [PostType.Brief]: BriefPostModal, [PostType.Poll]: PollPostModal, + [PostType.Tweet]: TweetPostModal, }; export default function Feed({ diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index ee3baa8292..323eca5b76 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -45,6 +45,7 @@ import { MarketingCtaBriefing } from './marketingCta/MarketingCtaBriefing'; import { MarketingCtaYearInReview } from './marketingCta/MarketingCtaYearInReview'; import PollGrid from './cards/poll/PollGrid'; import { PollList } from './cards/poll/PollList'; +import { TweetGrid, TweetList } from './cards/tweet'; export type FeedItemComponentProps = { item: FeedItem; @@ -104,6 +105,7 @@ const PostTypeToTagCard: Record = { [PostType.Collection]: CollectionGrid, [PostType.Brief]: BriefCard, [PostType.Poll]: PollGrid, + [PostType.Tweet]: TweetGrid, }; const PostTypeToTagList: Record = { @@ -115,6 +117,7 @@ const PostTypeToTagList: Record = { [PostType.Collection]: CollectionList, [PostType.Brief]: BriefCard, [PostType.Poll]: PollList, + [PostType.Tweet]: TweetList, }; type GetTagsProps = { diff --git a/packages/shared/src/components/cards/tweet/TweetCardSkeleton.tsx b/packages/shared/src/components/cards/tweet/TweetCardSkeleton.tsx new file mode 100644 index 0000000000..6c0aec5573 --- /dev/null +++ b/packages/shared/src/components/cards/tweet/TweetCardSkeleton.tsx @@ -0,0 +1,95 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ElementPlaceholder } from '../../ElementPlaceholder'; +import { TwitterIcon } from '../../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { Loader } from '../../Loader'; + +export interface TweetCardSkeletonProps { + className?: string; + authorName?: string; + authorUsername?: string; + authorAvatar?: string; +} + +/** + * Skeleton component for tweet cards while the tweet content is being processed. + * Shows Twitter branding and optional author info from the preview. + */ +export function TweetCardSkeleton({ + className, + authorName, + authorUsername, + authorAvatar, +}: TweetCardSkeletonProps): ReactElement { + return ( +
+ {/* Header with author info or placeholder */} +
+
+ {authorAvatar ? ( + {authorName + ) : ( + + )} +
+ {authorName ? ( + + {authorName} + + ) : ( + + )} + {authorUsername ? ( + + @{authorUsername} + + ) : ( + + )} +
+
+ +
+ + {/* Content placeholder with loading indicator */} +
+ + + +
+ + {/* Loading message */} +
+ + + Loading tweet... + +
+
+ ); +} + +export default TweetCardSkeleton; diff --git a/packages/shared/src/components/cards/tweet/TweetGrid.spec.tsx b/packages/shared/src/components/cards/tweet/TweetGrid.spec.tsx new file mode 100644 index 0000000000..018d5f7ef2 --- /dev/null +++ b/packages/shared/src/components/cards/tweet/TweetGrid.spec.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { mocked } from 'ts-jest/utils'; +import { tweetPost, tweetPostWithMedia } from '../../../../__tests__/fixture/tweetPost'; +import type { PostCardProps } from '../common/common'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { TweetGrid } from './TweetGrid'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + } as unknown as NextRouter), + ); +}); + +const defaultProps: PostCardProps = { + post: tweetPost, + onPostClick: jest.fn(), + onUpvoteClick: jest.fn(), + onCommentClick: jest.fn(), + onBookmarkClick: jest.fn(), + onShare: jest.fn(), + onCopyLinkClick: jest.fn(), + onReadArticleClick: jest.fn(), +}; + +const renderComponent = (props: Partial = {}): RenderResult => { + return render( + + + , + ); +}; + +it('should render tweet author username', async () => { + renderComponent(); + const el = await screen.findByText('@dailydotdev'); + expect(el).toBeInTheDocument(); +}); + +it('should render tweet author name', async () => { + renderComponent(); + const el = await screen.findByText('daily.dev'); + expect(el).toBeInTheDocument(); +}); + +it('should render tweet content', async () => { + renderComponent(); + const el = await screen.findByText( + /Check out our latest feature release!/, + ); + expect(el).toBeInTheDocument(); +}); + +it('should render X icon with link to tweet', async () => { + renderComponent(); + const el = await screen.findByLabelText('View on X'); + expect(el).toBeInTheDocument(); + expect(el).toHaveAttribute( + 'href', + 'https://x.com/dailydotdev/status/1234567890123456789', + ); +}); + +it('should call onPostClick when clicking the card', async () => { + renderComponent(); + const el = await screen.findByText(/Check out our latest feature release!/); + el.click(); + await waitFor(() => expect(defaultProps.onPostClick).toBeCalled()); +}); + +it('should call onUpvoteClick on upvote button click', async () => { + renderComponent(); + const el = await screen.findByLabelText('More like this'); + el.click(); + await waitFor(() => + expect(defaultProps.onUpvoteClick).toBeCalledWith(tweetPost), + ); +}); + +it('should call onCommentClick on comment button click', async () => { + renderComponent(); + const el = await screen.findByLabelText('Comments'); + el.click(); + await waitFor(() => + expect(defaultProps.onCommentClick).toBeCalledWith(tweetPost), + ); +}); + +it('should call onCopyLinkClick on copy link button click', async () => { + renderComponent(); + const el = await screen.findByLabelText('Copy link'); + el.click(); + await waitFor(() => expect(defaultProps.onCopyLinkClick).toBeCalled()); +}); + +it('should render verified badge for verified author', async () => { + renderComponent(); + const el = await screen.findByTestId('verified-badge'); + expect(el).toBeInTheDocument(); +}); + +it('should not render verified badge for unverified author', async () => { + renderComponent({ + post: { + ...tweetPost, + tweetAuthorVerified: false, + }, + }); + const el = screen.queryByTestId('verified-badge'); + expect(el).not.toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/cards/tweet/TweetGrid.tsx b/packages/shared/src/components/cards/tweet/TweetGrid.tsx new file mode 100644 index 0000000000..d5c7b79eaf --- /dev/null +++ b/packages/shared/src/components/cards/tweet/TweetGrid.tsx @@ -0,0 +1,143 @@ +import type { ReactElement, Ref } from 'react'; +import React, { forwardRef } from 'react'; +import classNames from 'classnames'; +import type { PostCardProps } from '../common/common'; +import { Container } from '../common/common'; +import FeedItemContainer from '../common/FeedItemContainer'; +import { + CardSpace, + CardTextContainer, + CardTitle, + getPostClassNames, +} from '../common/Card'; +import CardOverlay from '../common/CardOverlay'; +import { PostCardHeader } from '../common/PostCardHeader'; +import { PostCardFooter } from '../common/PostCardFooter'; +import ActionButtons from '../ActionsButtons'; +import { TweetAuthorHeader } from '../../post/tweet/TweetAuthorHeader'; +import { TwitterIcon } from '../../icons'; +import { ProfileImageSize } from '../../ProfilePicture'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; + +export const TweetGrid = forwardRef(function TweetGrid( + { + post, + onPostClick, + onPostAuxClick, + onUpvoteClick, + onDownvoteClick, + onCommentClick, + onBookmarkClick, + onShare, + onCopyLinkClick, + openNewTab, + children, + onReadArticleClick, + domProps = {}, + eagerLoadImage = false, + }: PostCardProps, + ref: Ref, +): ReactElement { + const { className, style } = domProps; + const onPostCardClick = () => onPostClick(post); + const onPostCardAuxClick = () => onPostAuxClick(post); + const { pinnedAt, trending } = post; + const { title } = useSmartTitle(post); + + // Get tweet data from sharedPost for share posts, or directly from post + const tweetPost = post.sharedPost || post; + const tweetUrl = tweetPost.tweetId + ? `https://x.com/${tweetPost.tweetAuthorUsername}/status/${tweetPost.tweetId}` + : null; + + return ( + + + +
+ + + {/* Show commentary title for share posts */} + {post.sharedPost && title && {title}} + + + + + {/* Tweet author info with X logo */} +
+ {tweetPost.tweetAuthorUsername && ( + + )} + {tweetUrl && ( + e.stopPropagation()} + > + + + )} +
+ + {/* Tweet content preview */} + {tweetPost.tweetContent && ( +

+ {tweetPost.tweetContent} +

+ )} +
+ + + + + + +
+ {children} +
+ ); +}); diff --git a/packages/shared/src/components/cards/tweet/TweetList.spec.tsx b/packages/shared/src/components/cards/tweet/TweetList.spec.tsx new file mode 100644 index 0000000000..d6aa39e500 --- /dev/null +++ b/packages/shared/src/components/cards/tweet/TweetList.spec.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { mocked } from 'ts-jest/utils'; +import { tweetPost, tweetPostWithMedia } from '../../../../__tests__/fixture/tweetPost'; +import type { PostCardProps } from '../common/common'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { TweetList } from './TweetList'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + } as unknown as NextRouter), + ); +}); + +const defaultProps: PostCardProps = { + post: tweetPost, + onPostClick: jest.fn(), + onUpvoteClick: jest.fn(), + onCommentClick: jest.fn(), + onBookmarkClick: jest.fn(), + onShare: jest.fn(), + onCopyLinkClick: jest.fn(), + onReadArticleClick: jest.fn(), +}; + +const renderComponent = (props: Partial = {}): RenderResult => { + return render( + + + , + ); +}; + +it('should render tweet content', async () => { + renderComponent(); + const el = await screen.findByText( + /Check out our latest feature release!/, + ); + expect(el).toBeInTheDocument(); +}); + +it('should render X icon with link to tweet', async () => { + renderComponent(); + const el = await screen.findByLabelText('View on X'); + expect(el).toBeInTheDocument(); + expect(el).toHaveAttribute( + 'href', + 'https://x.com/dailydotdev/status/1234567890123456789', + ); +}); + +it('should call onPostClick when clicking the card', async () => { + renderComponent(); + const el = await screen.findByText(/Check out our latest feature release!/); + el.click(); + await waitFor(() => expect(defaultProps.onPostClick).toBeCalled()); +}); + +it('should call onUpvoteClick on upvote button click', async () => { + renderComponent(); + const el = await screen.findByLabelText('More like this'); + el.click(); + await waitFor(() => + expect(defaultProps.onUpvoteClick).toBeCalledWith(tweetPost), + ); +}); + +it('should call onCommentClick on comment button click', async () => { + renderComponent(); + const el = await screen.findByLabelText('Comments'); + el.click(); + await waitFor(() => + expect(defaultProps.onCommentClick).toBeCalledWith(tweetPost), + ); +}); + +it('should call onCopyLinkClick on copy link button click', async () => { + renderComponent(); + const el = await screen.findByLabelText('Copy link'); + el.click(); + await waitFor(() => expect(defaultProps.onCopyLinkClick).toBeCalled()); +}); + +it('should render tweet media preview when available', async () => { + renderComponent({ post: tweetPostWithMedia }); + const el = await screen.findByAltText('Tweet media'); + expect(el).toBeInTheDocument(); +}); + +it('should not render tweet media preview when not available', async () => { + renderComponent(); + const el = screen.queryByAltText('Tweet media'); + expect(el).not.toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/cards/tweet/TweetList.tsx b/packages/shared/src/components/cards/tweet/TweetList.tsx new file mode 100644 index 0000000000..f3827f46e3 --- /dev/null +++ b/packages/shared/src/components/cards/tweet/TweetList.tsx @@ -0,0 +1,188 @@ +import type { ReactElement, Ref } from 'react'; +import React, { forwardRef, useMemo } from 'react'; +import type { PostCardProps } from '../common/common'; +import { Container } from '../common/common'; +import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import FeedItemContainer from '../common/list/FeedItemContainer'; +import { PostCardHeader } from '../common/list/PostCardHeader'; +import SourceButton from '../common/SourceButton'; +import { ProfileImageSize } from '../../ProfilePicture'; +import { CardContent, CardTitle } from '../common/list/ListCard'; +import ActionButtons from '../common/list/ActionButtons'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { isSourceUserSource } from '../../../graphql/sources'; +import { TwitterIcon } from '../../icons'; +import { TweetAuthorHeader } from '../../post/tweet/TweetAuthorHeader'; + +export const TweetList = forwardRef(function TweetList( + { + post, + onPostClick, + onUpvoteClick, + onDownvoteClick, + onCommentClick, + onCopyLinkClick, + onBookmarkClick, + children, + openNewTab, + onReadArticleClick, + enableSourceHeader = false, + domProps = {}, + }: PostCardProps, + ref: Ref, +): ReactElement { + const { pinnedAt, trending, type } = post; + const isMobile = useViewSize(ViewSize.MobileL); + const onPostCardClick = () => onPostClick(post); + const isFeedPreview = useFeedPreviewMode(); + const { title } = useSmartTitle(post); + const isUserSource = isSourceUserSource(post.source); + + // Get tweet data from sharedPost for share posts, or directly from post + const tweetPost = post.sharedPost || post; + const tweetUrl = tweetPost.tweetId + ? `https://x.com/${tweetPost.tweetAuthorUsername}/status/${tweetPost.tweetId}` + : null; + + const actionButtons = ( + + + + ); + + const metadata = useMemo(() => { + if (isUserSource) { + return { + topLabel: post.author?.name, + }; + } + + return { + topLabel: enableSourceHeader ? post.source?.name : post.author?.name, + bottomLabel: enableSourceHeader + ? post.author?.name + : tweetPost.tweetAuthorUsername + ? `@${tweetPost.tweetAuthorUsername}` + : undefined, + }; + }, [ + enableSourceHeader, + isUserSource, + post?.author?.name, + post?.source?.name, + tweetPost?.tweetAuthorUsername, + ]); + + return ( + + + {!isUserSource && ( + + )} + + + +
+ {/* Show commentary title for share posts, or tweet content */} + {post.sharedPost && title ? ( + + {title} + + ) : ( + tweetPost.tweetContent && ( + + {tweetPost.tweetContent} + + ) + )} + +
+ + {/* Tweet author info */} +
+ {tweetPost.tweetAuthorUsername && ( + + )} + {tweetUrl && ( + e.stopPropagation()} + > + + + )} +
+ +
+ {!isMobile && actionButtons} +
+ + {/* Tweet media preview if available */} + {tweetPost.tweetMedia && tweetPost.tweetMedia.length > 0 && ( +
+ {tweetPost.tweetMedia[0].type === 'image' && ( + Tweet media + )} +
+ )} + + + {isMobile && actionButtons} + {children} + + ); +}); diff --git a/packages/shared/src/components/cards/tweet/index.ts b/packages/shared/src/components/cards/tweet/index.ts new file mode 100644 index 0000000000..102450a74e --- /dev/null +++ b/packages/shared/src/components/cards/tweet/index.ts @@ -0,0 +1,6 @@ +export { TweetGrid } from './TweetGrid'; +export { TweetList } from './TweetList'; +export { + TweetCardSkeleton, + type TweetCardSkeletonProps, +} from './TweetCardSkeleton'; diff --git a/packages/shared/src/components/modals/TweetPostModal.tsx b/packages/shared/src/components/modals/TweetPostModal.tsx new file mode 100644 index 0000000000..8bf43cd8bc --- /dev/null +++ b/packages/shared/src/components/modals/TweetPostModal.tsx @@ -0,0 +1,65 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { ModalProps } from './common/Modal'; +import { Modal, modalSizeToClassName } from './common/Modal'; +import usePostNavigationPosition from '../../hooks/usePostNavigationPosition'; +import BasePostModal from './BasePostModal'; +import type { Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import type { PassedPostNavigationProps } from '../post/common'; +import { Origin } from '../../lib/log'; +import { TweetPostContent } from '../post/tweet/TweetPostContent'; + +interface TweetPostModalProps extends ModalProps, PassedPostNavigationProps { + id: string; + post: Post; +} + +export default function TweetPostModal({ + id, + className, + onRequestClose, + onPreviousPost, + onNextPost, + postPosition, + post, + ...props +}: TweetPostModalProps): ReactElement { + const { position, onLoad } = usePostNavigationPosition({ + isDisplayed: props.isOpen, + offset: 0, + }); + return ( + + + + ); +} diff --git a/packages/shared/src/components/post/SquadPostContent.tsx b/packages/shared/src/components/post/SquadPostContent.tsx index 630e0dccac..c617d0696d 100644 --- a/packages/shared/src/components/post/SquadPostContent.tsx +++ b/packages/shared/src/components/post/SquadPostContent.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import PostContentContainer from './PostContentContainer'; import usePostContent from '../../hooks/usePostContent'; import { BasePostContent } from './BasePostContent'; -import { isVideoPost, PostType } from '../../graphql/posts'; +import { isVideoPost, isTweetPost, PostType } from '../../graphql/posts'; import { useMemberRoleForSource } from '../../hooks/useMemberRoleForSource'; import SquadPostAuthor from './SquadPostAuthor'; import SharePostContent from './SharePostContent'; @@ -13,6 +13,7 @@ import { SquadPostWidgets } from './SquadPostWidgets'; import { useAuthContext } from '../../contexts/AuthContext'; import type { PostContentProps, PostNavigationProps } from './common'; import ShareYouTubeContent from './ShareYouTubeContent'; +import { TweetPostContent } from './tweet'; import { useViewPost } from '../../hooks/post'; import { withPostById } from './withPostById'; import PostSourceInfo from './PostSourceInfo'; @@ -28,6 +29,7 @@ const ContentMap = { [PostType.Welcome]: MarkdownPostContent, [PostType.Share]: SharePostContent, [PostType.VideoYouTube]: ShareYouTubeContent, + [PostType.Tweet]: TweetPostContent, }; function SquadPostContentRaw({ @@ -85,7 +87,13 @@ function SquadPostContentRaw({ onSendViewPost(post.id); }, [post.id, onSendViewPost, user?.id]); - const finalType = isVideoPost(post) ? PostType.VideoYouTube : post?.type; + // Determine the correct content type for the post + const getFinalType = (): PostType => { + if (isVideoPost(post)) return PostType.VideoYouTube; + if (isTweetPost(post)) return PostType.Tweet; + return post?.type; + }; + const finalType = getFinalType(); const Content = ContentMap[finalType]; return ( diff --git a/packages/shared/src/components/post/tweet/TweetAuthorHeader.tsx b/packages/shared/src/components/post/tweet/TweetAuthorHeader.tsx new file mode 100644 index 0000000000..86a1701c0e --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetAuthorHeader.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; +import { VerifiedIcon } from '../../icons'; + +export interface TweetAuthorHeaderProps { + username: string; + name: string; + avatar: string; + verified?: boolean; + createdAt?: string; + className?: string; +} + +export function TweetAuthorHeader({ + username, + name, + avatar, + verified, + createdAt, + className, +}: TweetAuthorHeaderProps): ReactElement { + const twitterProfileUrl = `https://x.com/${username}`; + + return ( +
+ + + + + {createdAt && ( + + )} +
+ ); +} diff --git a/packages/shared/src/components/post/tweet/TweetContent.tsx b/packages/shared/src/components/post/tweet/TweetContent.tsx new file mode 100644 index 0000000000..414b139aeb --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetContent.tsx @@ -0,0 +1,119 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +export interface TweetContentProps { + content: string; + contentHtml?: string; + className?: string; +} + +export function TweetContent({ + content, + contentHtml, + className, +}: TweetContentProps): ReactElement { + // If we have pre-rendered HTML, use it + if (contentHtml) { + return ( +
+ ); + } + + // Otherwise, parse and render the content with links + const parseTwitterContent = (text: string): ReactElement[] => { + const parts: ReactElement[] = []; + let lastIndex = 0; + let keyIndex = 0; + + // Combined regex for @mentions, #hashtags, and URLs + const regex = + /(@\w+)|(#\w+)|(https?:\/\/[^\s]+)/g; + let match; + + while ((match = regex.exec(text)) !== null) { + // Add text before the match + if (match.index > lastIndex) { + parts.push( + + {text.slice(lastIndex, match.index)} + , + ); + } + + const [fullMatch, mention, hashtag, url] = match; + + if (mention) { + // @mention - link to Twitter profile + const username = mention.slice(1); + parts.push( + + {mention} + , + ); + } else if (hashtag) { + // #hashtag - link to Twitter search + const tag = hashtag.slice(1); + parts.push( + + {hashtag} + , + ); + } else if (url) { + // URL - clickable link + parts.push( + + {url.length > 30 ? `${url.slice(0, 30)}...` : url} + , + ); + } + + lastIndex = match.index + fullMatch.length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push( + {text.slice(lastIndex)}, + ); + } + + return parts; + }; + + return ( +
+ {parseTwitterContent(content)} +
+ ); +} diff --git a/packages/shared/src/components/post/tweet/TweetContentLoading.tsx b/packages/shared/src/components/post/tweet/TweetContentLoading.tsx new file mode 100644 index 0000000000..5d78c4f625 --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetContentLoading.tsx @@ -0,0 +1,137 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ElementPlaceholder } from '../../ElementPlaceholder'; +import { TwitterIcon } from '../../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { Loader } from '../../Loader'; + +export interface TweetContentLoadingProps { + className?: string; + url?: string; + authorName?: string; + authorUsername?: string; + authorAvatar?: string; + showEstimate?: boolean; +} + +/** + * Full loading component for tweet post detail view while the tweet is being processed. + * Shows Twitter branding, partial info from preview, and progress indicator. + */ +export function TweetContentLoading({ + className, + url, + authorName, + authorUsername, + authorAvatar, + showEstimate = true, +}: TweetContentLoadingProps): ReactElement { + return ( +
+ {/* Twitter branding */} +
+ +
+ + {/* Loading indicator */} +
+ +
+ + {/* Status message */} + + Loading tweet content + + + + We're fetching the tweet details from X. This usually takes just a few seconds. + + + {/* Preview info if available */} + {(authorName || authorUsername || url) && ( +
+ {/* Author preview */} + {(authorName || authorUsername) && ( +
+ {authorAvatar ? ( + {authorName + ) : ( + + )} +
+ {authorName && ( + + {authorName} + + )} + {authorUsername && ( + + @{authorUsername} + + )} +
+
+ )} + + {/* URL preview */} + {url && ( + + {url} + + )} + + {/* Content placeholder */} +
+ + + +
+
+ )} + + {/* Estimate message */} + {showEstimate && ( + + Usually takes a few seconds + + )} +
+ ); +} + +export default TweetContentLoading; diff --git a/packages/shared/src/components/post/tweet/TweetMediaGallery.tsx b/packages/shared/src/components/post/tweet/TweetMediaGallery.tsx new file mode 100644 index 0000000000..880a7237d1 --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetMediaGallery.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { TweetMedia } from '../../../graphql/posts'; +import { TweetMediaType } from '../../../graphql/posts'; +import { PlayIcon } from '../../icons'; +import { LazyImage } from '../../LazyImage'; + +export interface TweetMediaGalleryProps { + media: TweetMedia[]; + className?: string; +} + +/** + * TweetMediaGallery renders tweet media (images and videos) in a responsive grid layout. + * Supports 1-4 media items with different grid configurations. + */ +export function TweetMediaGallery({ + media, + className, +}: TweetMediaGalleryProps): ReactElement | null { + const [expandedMedia, setExpandedMedia] = useState(null); + + if (!media || media.length === 0) { + return null; + } + + // Grid layout classes based on number of media items + const getGridClass = () => { + switch (media.length) { + case 1: + return 'grid-cols-1'; + case 2: + return 'grid-cols-2'; + case 3: + return 'grid-cols-2'; + case 4: + default: + return 'grid-cols-2'; + } + }; + + const handleMediaClick = (index: number, item: TweetMedia) => { + // For videos, don't expand - open in new tab or play inline + if (item.type === TweetMediaType.Video || item.type === TweetMediaType.Gif) { + window.open(item.url, '_blank'); + return; + } + + setExpandedMedia(expandedMedia === index ? null : index); + }; + + const renderMediaItem = (item: TweetMedia, index: number) => { + const isVideo = + item.type === TweetMediaType.Video || item.type === TweetMediaType.Gif; + const isExpanded = expandedMedia === index; + + // For 3 items, first item spans full row + const isFirstOfThree = media.length === 3 && index === 0; + + return ( + + ); + }; + + return ( +
+ {media.slice(0, 4).map((item, index) => renderMediaItem(item, index))} +
+ ); +} + +export default TweetMediaGallery; diff --git a/packages/shared/src/components/post/tweet/TweetPostContent.tsx b/packages/shared/src/components/post/tweet/TweetPostContent.tsx new file mode 100644 index 0000000000..bbb25a9fb1 --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetPostContent.tsx @@ -0,0 +1,169 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { TweetAuthorHeader } from './TweetAuthorHeader'; +import { TweetContent } from './TweetContent'; +import { TweetMediaGallery } from './TweetMediaGallery'; +import { TweetThread } from './TweetThread'; +import { SharePostTitle } from '../share'; +import { TwitterIcon, ExternalLinkIcon } from '../../icons'; +import { Button, ButtonVariant, ButtonSize } from '../../buttons/Button'; +import type { PostContentProps, PostNavigationProps } from '../common'; +import { PostContainer } from '../common'; +import { BasePostContent } from '../BasePostContent'; +import usePostContent from '../../../hooks/usePostContent'; +import { PostWidgets } from '../PostWidgets'; + +export interface TweetPostContentProps extends PostContentProps {} + +export function TweetPostContent({ + post, + className = {}, + origin, + position, + inlineActions, + onPreviousPost, + onNextPost, + onClose, + postPosition, + isFallback, + isBannerVisible, + isPostPage, +}: TweetPostContentProps): ReactElement { + const engagementActions = usePostContent({ + origin, + post, + }); + const { onReadArticle } = engagementActions; + + // For shared posts, get the tweet data from sharedPost + const tweetPost = post?.sharedPost || post; + + const tweetUrl = tweetPost?.tweetId + ? `https://x.com/${tweetPost.tweetAuthorUsername}/status/${tweetPost.tweetId}` + : null; + + const navigationProps: PostNavigationProps = { + postPosition, + onPreviousPost, + onNextPost, + post, + onReadArticle, + onClose, + inlineActions, + }; + + const containerClass = classNames( + 'laptop:flex-row laptop:pb-0', + className?.container, + ); + + return ( + + + {/* Show commentary/title if this is a share post */} + {post?.sharedPost && post?.title && ( + + )} + + {/* Tweet container */} +
+ {/* Tweet header with author info and X logo */} +
+ {tweetPost?.tweetAuthorUsername && ( + + )} + + + +
+ + {/* Tweet content */} + {tweetPost?.tweetContent && ( + + )} + + {/* Tweet media gallery */} + {tweetPost?.tweetMedia && tweetPost.tweetMedia.length > 0 && ( + + )} + + {/* Thread tweets */} + {tweetPost?.isThread && + tweetPost?.threadTweets && + tweetPost.threadTweets.length > 0 && ( + + )} + + {/* View on X button */} + {tweetUrl && ( +
+ +
+ )} +
+
+ +
+ ); +} + +export default TweetPostContent; diff --git a/packages/shared/src/components/post/tweet/TweetProcessingError.tsx b/packages/shared/src/components/post/tweet/TweetProcessingError.tsx new file mode 100644 index 0000000000..1dc5882b23 --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetProcessingError.tsx @@ -0,0 +1,105 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { TwitterIcon, BlockIcon } from '../../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { Button, ButtonVariant, ButtonSize } from '../../buttons/Button'; + +export interface TweetProcessingErrorProps { + className?: string; + url?: string; + errorMessage?: string; + onRetry?: () => void; + showViewOnX?: boolean; +} + +/** + * Error component displayed when tweet processing fails. + * Shows error message and provides retry option. + */ +export function TweetProcessingError({ + className, + url, + errorMessage = 'We couldn\'t load this tweet', + onRetry, + showViewOnX = true, +}: TweetProcessingErrorProps): ReactElement { + return ( +
+ {/* Error icon with Twitter branding */} +
+
+ +
+
+ +
+
+ + {/* Error message */} + + {errorMessage} + + + + The tweet may have been deleted, set to private, or there was an issue fetching its content. + + + {/* Action buttons */} +
+ {onRetry && ( + + )} + {showViewOnX && url && ( + + )} +
+ + {/* URL display */} + {url && ( + + {url} + + )} +
+ ); +} + +export default TweetProcessingError; diff --git a/packages/shared/src/components/post/tweet/TweetThread.tsx b/packages/shared/src/components/post/tweet/TweetThread.tsx new file mode 100644 index 0000000000..b174b8fb1a --- /dev/null +++ b/packages/shared/src/components/post/tweet/TweetThread.tsx @@ -0,0 +1,157 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { TweetData } from '../../../graphql/posts'; +import { TweetContent } from './TweetContent'; +import { TweetMediaGallery } from './TweetMediaGallery'; +import { Button, ButtonVariant, ButtonSize } from '../../buttons/Button'; +import { ArrowIcon } from '../../icons'; + +export interface TweetThreadProps { + threadTweets: TweetData[]; + authorUsername: string; + className?: string; + initialExpanded?: boolean; + maxPreviewCount?: number; +} + +/** + * TweetThread displays a collection of tweets from a thread. + * Shows a collapsed view by default with an option to expand and see all tweets. + */ +export function TweetThread({ + threadTweets, + authorUsername, + className, + initialExpanded = false, + maxPreviewCount = 2, +}: TweetThreadProps): ReactElement | null { + const [isExpanded, setIsExpanded] = useState(initialExpanded); + + if (!threadTweets || threadTweets.length === 0) { + return null; + } + + const displayedTweets = isExpanded + ? threadTweets + : threadTweets.slice(0, maxPreviewCount); + const hasMoreTweets = threadTweets.length > maxPreviewCount; + const remainingCount = threadTweets.length - maxPreviewCount; + + return ( +
+ {/* Thread header */} +
+
+ Thread by @{authorUsername} + + ยท {threadTweets.length + 1} tweets + +
+ + {/* Thread tweets */} +
+ {displayedTweets.map((tweet, index) => ( + + ))} +
+ + {/* Expand/Collapse button */} + {hasMoreTweets && ( +
+ +
+ )} +
+ ); +} + +interface ThreadTweetItemProps { + tweet: TweetData; + index: number; + isLast: boolean; +} + +function ThreadTweetItem({ tweet, index, isLast }: ThreadTweetItemProps): ReactElement { + const tweetUrl = tweet.tweetId && tweet.authorUsername + ? `https://x.com/${tweet.authorUsername}/status/${tweet.tweetId}` + : null; + + return ( +
+ {/* Connection line */} + + ); +} + +export default TweetThread; diff --git a/packages/shared/src/components/post/tweet/index.ts b/packages/shared/src/components/post/tweet/index.ts new file mode 100644 index 0000000000..5b29d7810f --- /dev/null +++ b/packages/shared/src/components/post/tweet/index.ts @@ -0,0 +1,16 @@ +export { TweetPostContent, type TweetPostContentProps } from './TweetPostContent'; +export { TweetAuthorHeader, type TweetAuthorHeaderProps } from './TweetAuthorHeader'; +export { TweetContent, type TweetContentProps } from './TweetContent'; +export { + TweetMediaGallery, + type TweetMediaGalleryProps, +} from './TweetMediaGallery'; +export { TweetThread, type TweetThreadProps } from './TweetThread'; +export { + TweetContentLoading, + type TweetContentLoadingProps, +} from './TweetContentLoading'; +export { + TweetProcessingError, + type TweetProcessingErrorProps, +} from './TweetProcessingError'; diff --git a/packages/shared/src/components/post/write/SubmitExternalLink.tsx b/packages/shared/src/components/post/write/SubmitExternalLink.tsx index bd961d1c16..fd5d916f93 100644 --- a/packages/shared/src/components/post/write/SubmitExternalLink.tsx +++ b/packages/shared/src/components/post/write/SubmitExternalLink.tsx @@ -13,6 +13,11 @@ import { useLazyModal } from '../../../hooks/useLazyModal'; import { useViewSize, ViewSize } from '../../../hooks'; import { WritePreviewSkeleton } from './WritePreviewSkeleton'; import { WriteLinkPreview } from './WriteLinkPreview'; +import { + WriteTweetPreview, + isTwitterUrl, + extractTweetInfo, +} from './WriteTweetPreview'; import { useDebouncedUrl } from '../../../hooks/input'; interface SubmitExternalLinkProps { @@ -51,6 +56,20 @@ export function SubmitExternalLink({ } if (preview) { + // Use Twitter-specific preview for Twitter/X URLs + if (isTwitterUrl(link)) { + const { tweetId, username } = extractTweetInfo(link); + return ( + + ); + } + return ( ); diff --git a/packages/shared/src/components/post/write/WriteTweetPreview.tsx b/packages/shared/src/components/post/write/WriteTweetPreview.tsx new file mode 100644 index 0000000000..880f9c1dd8 --- /dev/null +++ b/packages/shared/src/components/post/write/WriteTweetPreview.tsx @@ -0,0 +1,186 @@ +import type { FormEventHandler, ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { TextField } from '../../fields/TextField'; +import { LinkIcon, TwitterIcon, OpenLinkIcon } from '../../icons'; +import { Image } from '../../image/Image'; +import { + previewImageClass, + WritePreviewContainer, + WritePreviewContent, +} from './common'; +import type { ExternalLinkPreview } from '../../../graphql/posts'; +import { Button, ButtonVariant } from '../../buttons/Button'; +import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; + +interface WriteTweetPreviewProps { + link: string; + preview: ExternalLinkPreview; + onLinkChange?: FormEventHandler; + className?: string; + showPreviewLink?: boolean; + isMinimized?: boolean; + tweetId?: string; + tweetUsername?: string; +} + +/** + * Twitter/X-specific link preview component. + * Shows a Twitter-styled preview when a Twitter URL is detected. + */ +export function WriteTweetPreview({ + link, + preview, + onLinkChange, + className, + isMinimized, + showPreviewLink = true, + tweetId, + tweetUsername, +}: WriteTweetPreviewProps): ReactElement { + const tweetUrl = + tweetId && tweetUsername + ? `https://x.com/${tweetUsername}/status/${tweetId}` + : link; + + return ( + + {showPreviewLink && ( + } + label="URL" + type="url" + name="url" + inputId="preview_url" + fieldType="tertiary" + className={{ container: 'w-full' }} + value={link} + onInput={onLinkChange} + /> + )} + + {/* Twitter-styled preview */} + + {/* X logo badge */} +
+ +
+ +
+ {/* Author info */} + {preview.source?.id !== 'unknown' && ( +
+ {preview.source?.image ? ( + + ) : ( +
+ )} +
+ + {preview.source?.name || 'Twitter User'} + + {tweetUsername && ( + + @{tweetUsername} + + )} +
+
+ )} + + {/* Tweet content preview */} + + {preview.title} + + + {/* Processing notice */} + + Tweet content will be fetched after submission + +
+ + {/* Tweet image preview if available */} + {preview.image && !isMinimized && ( + Tweet media + )} + + {/* View on X button */} + {!isMinimized && ( +