diff --git a/packages/shared/src/components/ShareBar.tsx b/packages/shared/src/components/ShareBar.tsx index 6b9461f11cf..59c49395089 100644 --- a/packages/shared/src/components/ShareBar.tsx +++ b/packages/shared/src/components/ShareBar.tsx @@ -20,6 +20,8 @@ import { useGetShortUrl } from '../hooks'; import { ReferralCampaignKey } from '../lib'; import { ProfileImageSize } from './ProfilePicture'; import { useAuthContext } from '../contexts/AuthContext'; +import { useSharePostPage } from '../hooks/useSharePostPage'; +import { ShareModuleHeader } from './post/share/ShareModuleHeader'; interface ShareBarProps { post: Post; @@ -41,6 +43,7 @@ export default function ShareBar({ post }: ShareBarProps): ReactElement { const { openModal } = useLazyModal(); const { logOpts } = useContext(ActiveFeedContext); const { squads } = useAuthContext(); + const isSharePostPageEnabled = useSharePostPage(); const shareableSquadsCount = useMemo( () => getShareableSquads(squads).length, @@ -91,13 +94,23 @@ export default function ShareBar({ post }: ShareBarProps): ReactElement { return ( -

- Would you recommend this post? -

+ {isSharePostPageEnabled ? ( + + ) : ( +

+ Would you recommend this post? +

+ )}
{ logEvent( @@ -47,6 +50,35 @@ export function ShareMobile({ openSharePost({ post }); }; + // Redesigned module: a reason to share up top, then two full-width buttons + // with a clear primary — instead of two same-weight coloured text links. + if (isSharePostPageEnabled) { + return ( + + + + + + ); + } + return (
)} diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index b3797e253c2..65cb50a5b37 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -10,6 +10,7 @@ import { AuthTriggers } from '../../lib/auth'; import type { NewCommentRef } from './NewComment'; import { NewComment } from './NewComment'; import { PostActions } from './PostActions'; +import { usePostActions } from '../../hooks/post/usePostActions'; import { PostComments } from './PostComments'; import { PostUpvotesCommentsCount } from './PostUpvotesCommentsCount'; import type { Comment } from '../../graphql/comments'; @@ -32,6 +33,8 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import SocialBar from '../cards/socials/SocialBar'; import { PostContentReminder } from './common/PostContentReminder'; import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useSharePostPage } from '../../hooks/useSharePostPage'; +import { ShareWithTeamStrip } from './share/ShareWithTeamStrip'; const AuthorOnboarding = dynamic( () => import(/* webpackChunkName: "authorOnboarding" */ './AuthorOnboarding'), @@ -77,6 +80,8 @@ function PostEngagements({ false, ); const [linkClicked, setLinkClicked] = useState(false); + const isSharePostPageEnabled = useSharePostPage(); + const { interaction } = usePostActions({ post }); const handleLinkClick = () => { setLinkClicked(true); @@ -126,6 +131,12 @@ function PostEngagements({ /> + {/* PostContentShare owns the engagement column right after an upvote, so + * the team strip stands down until that module is dismissed — otherwise + * two full-width share modules stack back-to-back. */} + {isSharePostPageEnabled && interaction !== 'upvote' && ( + + )} {linkClicked && } Sort: diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9d0706c024b..d21725b72ae 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -51,6 +51,9 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; +import { useSharePostPage } from '../../../hooks/useSharePostPage'; +import { CopySummaryButton } from '../share/CopySummaryButton'; +import { ShareWithTeamStrip } from '../share/ShareWithTeamStrip'; const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -250,6 +253,7 @@ export const PostFocusCard = ({ const { isReaderEnabled } = useReaderModalEligibility(); const isReaderVariant = isReaderEnabled && post.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); + const isSharePostPageEnabled = useSharePostPage(); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the @@ -324,6 +328,36 @@ export const PostFocusCard = ({ ) : null; + // Freeform/welcome posts render `contentHtml` instead and never carry a + // summary, so the copy action is absent there by construction. + const renderSummary = (): ReactElement | null => { + if (!article.summary) { + return null; + } + + const summary = isVideoType ? ( + + ) : ( +

+ {article.summary} +

+ ); + + if (!isSharePostPageEnabled) { + return summary; + } + + return ( +
+ {summary} + +
+ ); + }; + return (
) : ( - article.summary && - (isVideoType ? ( - - ) : ( -

- {article.summary} -

- )) + renderSummary() )} @@ -570,6 +594,8 @@ export const PostFocusCard = ({ className="-mt-2" /> + {isSharePostPageEnabled && } +
{ + const [copying, copyText] = useCopyText(); + const { getShortUrl } = useGetShortUrl(); + const { logEvent } = useLogContext(); + + if (!summary) { + return null; + } + + const onCopySummary = async () => { + const shortLink = await getShortUrl( + post.commentsPermalink, + ReferralCampaignKey.SharePost, + ); + await copyText({ + textToCopy: `${summary}\n\n${shortLink}`, + message: '✅ Copied summary to clipboard', + }); + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { + provider: ShareProvider.CopyLink, + origin: Origin.PostSummary, + }, + }), + ); + }; + + return ( + + + + ); +}; diff --git a/packages/shared/src/components/post/share/PostPageShare.spec.tsx b/packages/shared/src/components/post/share/PostPageShare.spec.tsx new file mode 100644 index 00000000000..5eb2f4bfe85 --- /dev/null +++ b/packages/shared/src/components/post/share/PostPageShare.spec.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import nock from 'nock'; +import { CopySummaryButton } from './CopySummaryButton'; +import { ShareWithTeamStrip } from './ShareWithTeamStrip'; +import { ShareMobile } from '../../ShareMobile'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import defaultPost from '../../../../__tests__/fixture/post'; +import type { Post } from '../../../graphql/posts'; +import { PostType } from '../../../graphql/posts'; +import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification'; +import { useViewSize } from '../../../hooks/useViewSize'; +import { useSharePostPage } from '../../../hooks/useSharePostPage'; +import { Origin } from '../../../lib/log'; + +jest.mock('../../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +jest.mock('../../../hooks/useSharePostPage', () => ({ + __esModule: true, + useSharePostPage: jest.fn(), +})); + +const useViewSizeMock = useViewSize as jest.Mock; +const useSharePostPageMock = useSharePostPage as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const nativeShare = jest.fn().mockResolvedValue(undefined); + +const summary = + 'React Compiler memoizes components automatically, so most useMemo calls become dead weight.'; + +const articlePost: Post = { ...defaultPost, summary }; +// Freeform posts are authored inside daily.dev and never carry a TL;DR. +const freeformPost: Post = { + ...defaultPost, + type: PostType.Freeform, + summary: undefined, +}; + +let client: QueryClient; + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + client = new QueryClient(); + useViewSizeMock.mockReturnValue(true); // default: laptop + useSharePostPageMock.mockReturnValue(true); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderComponent = (ui: React.ReactElement): RenderResult => + render({ui}); + +const getToast = () => + client.getQueryData<{ message: string }>(TOAST_NOTIF_KEY); + +describe('CopySummaryButton', () => { + it('copies the summary plus the post link and shows a toast', async () => { + const logEvent = jest.fn(); + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy summary')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `${summary}\n\n${articlePost.commentsPermalink}`, + ), + ); + expect(getToast()?.message).toEqual('✅ Copied summary to clipboard'); + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + extra: expect.stringContaining(Origin.PostSummary), + }), + ), + ); + }); + + it('renders nothing for a freeform post, which has no summary', () => { + renderComponent( + , + ); + + expect(screen.queryByLabelText('Copy summary')).not.toBeInTheDocument(); + }); +}); + +describe('ShareWithTeamStrip', () => { + it('copies the post link so it can be pasted into Slack', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByText('Send to Slack')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(articlePost.commentsPermalink), + ); + expect(getToast()?.message).toEqual( + '✅ Link copied — paste it in any Slack channel', + ); + }); + + it('uses the native share sheet on mobile', async () => { + useViewSizeMock.mockReturnValue(false); + Object.assign(navigator, { share: nativeShare, maxTouchPoints: 5 }); + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('More sharing options')); + }); + + await waitFor(() => expect(nativeShare).toHaveBeenCalled()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (navigator as any).share; + }); +}); + +describe('post page share flag', () => { + const renderShareMobile = () => + renderComponent( + , + ); + + it('keeps the existing share widget when the flag is off', () => { + useSharePostPageMock.mockReturnValue(false); + renderShareMobile(); + + expect( + screen.queryByText('Know someone who should read this?'), + ).not.toBeInTheDocument(); + expect(screen.getByText('Copy link')).toBeInTheDocument(); + expect(screen.getByText('Share with your friends')).toBeInTheDocument(); + }); + + it('adds the encouraging heading when the flag is on', () => { + renderShareMobile(); + + expect( + screen.getByText('Know someone who should read this?'), + ).toBeInTheDocument(); + expect(screen.getByText('Copy link')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/share/ShareModuleHeader.tsx b/packages/shared/src/components/post/share/ShareModuleHeader.tsx new file mode 100644 index 00000000000..77fd746566b --- /dev/null +++ b/packages/shared/src/components/post/share/ShareModuleHeader.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; + +/** + * Shared heading for the redesigned recommend module, so the desktop + * (`ShareBar`) and mobile (`ShareMobile`) widgets read as the same module. + * Reframes the old yes/no question ("Would you recommend this post?") as a + * reason to act. + */ +export const ShareModuleHeader = ({ + className, +}: { + className?: string; +}): ReactElement => ( +
+ + Know someone who should read this? + + + One link is all it takes — it is how most posts on daily.dev reach their + next reader. + +
+); diff --git a/packages/shared/src/components/post/share/ShareWithTeamStrip.tsx b/packages/shared/src/components/post/share/ShareWithTeamStrip.tsx new file mode 100644 index 00000000000..8ef801a0034 --- /dev/null +++ b/packages/shared/src/components/post/share/ShareWithTeamStrip.tsx @@ -0,0 +1,105 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { SlackIcon } from '../../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { ShareActions } from '../../share/ShareActions'; +import { useCopyPostLink } from '../../../hooks/useCopyPostLink'; +import { useGetShortUrl } from '../../../hooks'; +import { ReferralCampaignKey } from '../../../lib/referral'; +import { useLogContext } from '../../../contexts/LogContext'; +import { postLogEvent } from '../../../lib/feed'; +import { LogEvent, Origin } from '../../../lib/log'; +import { ShareProvider } from '../../../lib/share'; + +interface ShareWithTeamStripProps { + post: Post; + className?: string; +} + +/** + * Quiet band offering the single most common "share at work" move. + * + * Slack publishes no web share-intent URL (unlike X/WhatsApp/LinkedIn), so + * "Send to Slack" copies the post link: Slack unfurls a pasted daily.dev link + * into a rich card from the post's OG tags. A first-class Slack app posting via + * `chat.postMessage`/`chat.unfurl` is a backend dependency, not something the + * client can do on its own. + */ +export const ShareWithTeamStrip = ({ + post, + className, +}: ShareWithTeamStripProps): ReactElement => { + const [copying, copyLink] = useCopyPostLink(); + const { getShortUrl } = useGetShortUrl(); + const { logEvent } = useLogContext(); + + const logShare = (provider: ShareProvider) => + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: Origin.PostTeamShare }, + }), + ); + + const onSendToSlack = async () => { + const shortLink = await getShortUrl( + post.commentsPermalink, + ReferralCampaignKey.SharePost, + ); + copyLink({ + link: shortLink, + message: '✅ Link copied — paste it in any Slack channel', + }); + logShare(ShareProvider.CopyLink); + }; + + return ( +
+
+ + Share this with your team + + + Drop the link in a channel — it unfurls with the title and summary. + +
+
+ + +
+
+ ); +}; diff --git a/packages/shared/src/hooks/useSharePostPage.ts b/packages/shared/src/hooks/useSharePostPage.ts new file mode 100644 index 00000000000..bf05911e206 --- /dev/null +++ b/packages/shared/src/hooks/useSharePostPage.ts @@ -0,0 +1,18 @@ +import { featureSharePostPage } from '../lib/featureManagement'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useSharingVisibility } from './useSharingVisibility'; + +// Per-topic gate for the post page/modal sharing treatments (TL;DR copy, the +// team share strip, the redesigned recommend module). Stacked on top of the +// `sharing_visibility` master kill-switch so the topic flag is only evaluated +// once the initiative itself is on. +export const useSharePostPage = (shouldEvaluate = true): boolean => { + const { isEnabled: isInitiativeEnabled } = + useSharingVisibility(shouldEvaluate); + const { value } = useConditionalFeature({ + feature: featureSharePostPage, + shouldEvaluate: shouldEvaluate && isInitiativeEnabled, + }); + + return shouldEvaluate && isInitiativeEnabled && value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..0348a2ebb25 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,9 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Post page/modal sharing treatments: the TL;DR "Copy summary" action, the +// "share this with your team" strip and the redesigned recommend module. Gated +// on top of `sharing_visibility` so it can ramp on its own. Keep the default +// `false` — control is the current post page. +export const featureSharePostPage = new Feature('share_post_page', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9696707fa61..ee4e135d654 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,6 +108,8 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', + PostSummary = 'post summary', + PostTeamShare = 'post team share', } export enum LogEvent { diff --git a/packages/storybook/stories/components/share/PostPageShare.stories.tsx b/packages/storybook/stories/components/share/PostPageShare.stories.tsx new file mode 100644 index 00000000000..ff1665570a0 --- /dev/null +++ b/packages/storybook/stories/components/share/PostPageShare.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { CopySummaryButton } from '@dailydotdev/shared/src/components/post/share/CopySummaryButton'; +import { ShareWithTeamStrip } from '@dailydotdev/shared/src/components/post/share/ShareWithTeamStrip'; +import { ShareModuleHeader } from '@dailydotdev/shared/src/components/post/share/ShareModuleHeader'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { OpenLinkIcon } from '@dailydotdev/shared/src/components/icons'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; + +const summary = + 'React Compiler memoizes components automatically, so most useMemo and useCallback calls become dead weight. The article walks through what the compiler can and cannot infer, and where you still need to reach for manual memoization.'; + +const post = { + id: 'p1', + title: 'You can delete most of your useMemo calls', + permalink: 'https://daily.dev/r/p1', + commentsPermalink: 'https://app.daily.dev/posts/p1', + createdAt: '2026-05-01T00:00:00.000Z', + numUpvotes: 128, + numComments: 24, + summary, + type: PostType.Article, + source: { + id: 'react', + handle: 'reactjs', + name: 'React', + permalink: 'https://app.daily.dev/sources/reactjs', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/react', + type: SourceType.Machine, + }, +} as unknown as Post; + +// Freeform posts are authored inside daily.dev and never carry a generated +// TL;DR, so the copy-summary action has nothing to copy. +const freeformPost = { + ...post, + id: 'p2', + type: PostType.Freeform, + summary: undefined, +} as unknown as Post; + +const mockUser = { + id: '1', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + createdAt: '2024-01-01T00:00:00.000Z', + permalink: 'https://daily.dev/testuser', +} as unknown as LoggedUser; + +const meta: Meta = { + title: 'Components/Share/PostPageShare', + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Mock the short-URL resolution so copy/social actions don't hit network. + queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123'); + + const LogContext = getLogContextStatic(); + + return ( + + + false, + }} + > +
+ +
+
+
+
+ ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +// Wish-list #1 — the TL;DR block with its secondary copy action. "Read post" +// stays the only filled button so the copy action never competes with it. +export const CopySummary: Story = { + render: () => ( + <> +

+ {summary} +

+
+ + +
+ + ), +}; + +// Freeform posts have no summary — the action renders nothing at all. +export const CopySummaryOnFreeform: Story = { + render: () => ( +
+ + +
+ ), +}; + +// Wish-list #6 — the quiet team strip. "Send to Slack" copies the link, since +// Slack has no web share-intent URL; a pasted daily.dev link unfurls from OG. +export const TeamStrip: Story = { + render: () => , +}; + +// The strip collapses to a stacked layout below tablet. +export const TeamStripNarrow: Story = { + render: () => ( +
+ +
+ ), +}; + +// Wish-list #10 — the heading shared by the desktop and mobile recommend +// modules, replacing the old "Would you recommend this post?" question. +export const RecommendModuleHeader: Story = { + render: () => , +};