From dfd3cc25d3e67db1cef5e0de0297011e8ccd5499 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 27 Jul 2026 15:02:07 +0300 Subject: [PATCH 1/2] feat(share): surface a copy-link control on tag and source pages Share moves out of the buried "..." options menu into the header action row on both entity pages, as PR #6369's SplitShareButton: the label copies the link and the chevron drops the standard social list. - New `EntityShareAction` wraps `ShareActions` with the entity's link/text/cid and logs `ShareTag`/`ShareSource` with the provider and origin. `display` defaults to the split control; `icon` keeps the icon-only trigger. - Block leaves the row for the "..." menu, so the row is one Follow button plus identical secondary controls. Blocked is the exception: Unblock stays in the row, in the Follow slot that is empty then, and leaves the menu. - The notification bell takes ButtonVariant.Float so the bell, the copy control and the "..." button read as one treatment. - `CustomFeedOptionsMenu` gains `hideShare` so the in-menu Share entry is dropped where the visible control renders, never both. - `SourceActions` gains `showShare`; only /sources/[source] passes it, so the post page highlights widget keeps its existing row. No feature flag: this ships to everyone. Co-Authored-By: Claude Opus 5 --- .../src/components/CustomFeedOptionsMenu.tsx | 19 +- .../share/EntityShareAction.spec.tsx | 155 ++++ .../components/share/EntityShareAction.tsx | 82 ++ .../SourceActions/SourceActions.spec.tsx | 133 ++++ .../SourceActions/SourceActionsNotify.tsx | 16 +- .../sources/SourceActions/index.tsx | 81 +- .../src/components/tags/TagTopicPage.tsx | 71 +- .../components/EntityShareAction.stories.tsx | 705 ++++++++++++++++++ packages/webapp/__tests__/SourcePage.tsx | 59 +- packages/webapp/__tests__/TagPage.tsx | 66 +- .../webapp/__tests__/TagSourceSeo.spec.ts | 115 +++ packages/webapp/pages/sources/[source].tsx | 2 +- 12 files changed, 1434 insertions(+), 70 deletions(-) create mode 100644 packages/shared/src/components/share/EntityShareAction.spec.tsx create mode 100644 packages/shared/src/components/share/EntityShareAction.tsx create mode 100644 packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx create mode 100644 packages/storybook/stories/components/EntityShareAction.stories.tsx create mode 100644 packages/webapp/__tests__/TagSourceSeo.spec.ts diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.tsx index f4bc4b9bd64..3f70c426528 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.tsx @@ -28,6 +28,8 @@ type CustomFeedOptionsMenuProps = { buttonVariant?: ButtonVariant; shareProps: UseShareOrCopyLinkProps; additionalOptions?: MenuItemProps[]; + /** Drop the in-menu share entry when the surface renders a visible one. */ + hideShare?: boolean; }; const CustomFeedOptionsMenu = ({ @@ -38,13 +40,14 @@ const CustomFeedOptionsMenu = ({ onCreateNewFeed, additionalOptions = [], buttonVariant = ButtonVariant.Float, + hideShare = false, }: CustomFeedOptionsMenuProps): ReactElement => { const { openModal } = useLazyModal(); const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); const { feeds } = useFeeds(); const handleOpenModal = () => { - if (feeds?.edges?.length > 0) { + if (feeds?.edges?.length) { return openModal({ type: LazyModal.AddToCustomFeed, props: { @@ -59,11 +62,15 @@ const CustomFeedOptionsMenu = ({ }; const options: MenuItemProps[] = [ - { - icon: , - label: 'Share', - action: () => onShareOrCopyLink(), - }, + ...(hideShare + ? [] + : [ + { + icon: , + label: 'Share', + action: () => onShareOrCopyLink(), + }, + ]), { icon: , label: 'Add to custom feed', diff --git a/packages/shared/src/components/share/EntityShareAction.spec.tsx b/packages/shared/src/components/share/EntityShareAction.spec.tsx new file mode 100644 index 00000000000..3f034886d16 --- /dev/null +++ b/packages/shared/src/components/share/EntityShareAction.spec.tsx @@ -0,0 +1,155 @@ +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 type { EntityShareActionProps } from './EntityShareAction'; +import { EntityShareAction } from './EntityShareAction'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { ReferralCampaignKey } from '../../lib/referral'; +import type { ToastNotification } from '../../hooks/useToastNotification'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import { useViewSize } from '../../hooks/useViewSize'; + +jest.mock('../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const logEvent = jest.fn(); +const link = 'https://daily.dev/tags/webdev'; +const text = 'Check out the webdev tag on daily.dev'; + +let client: QueryClient; + +beforeEach(() => { + jest.clearAllMocks(); + client = new QueryClient(); + useViewSizeMock.mockReturnValue(true); // default: laptop + Object.assign(navigator, { clipboard: { writeText }, maxTouchPoints: 0 }); +}); + +const renderComponent = ( + display?: EntityShareActionProps['display'], +): RenderResult => + render( + + + , + ); + +const getToast = () => + client.getQueryData(TOAST_NOTIF_KEY) ?? null; + +/** The copy entry inside the share list, not the trigger that opens it. */ +const findListCopyEntry = () => screen.findByTestId('social-share-Copy link'); + +describe('EntityShareAction', () => { + it('renders the split copy control by default', () => { + renderComponent(); + + expect(screen.getByLabelText('Copy link')).toBeInTheDocument(); + expect(screen.getByLabelText('More share options')).toBeInTheDocument(); + }); + + it('renders an unlabelled share trigger in the icon display', () => { + renderComponent('icon'); + + expect(screen.getByLabelText('Share')).toBeInTheDocument(); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + + it('copies the link straight from the label and shows the copied toast', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + await waitFor(() => + expect(getToast()?.message).toEqual('✅ Copied link to clipboard'), + ); + // The label copies on its own — the list only opens from the chevron. + expect(screen.queryByTestId('social-share-X')).not.toBeInTheDocument(); + }); + + it('logs the entity share event with the provider and origin', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link')); + }); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareTag, + target_id: 'webdev', + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.TagPage, + }), + }), + ); + }); + + it('opens the share list from the chevron', async () => { + renderComponent(); + + // Radix's trigger opens on pointerdown/keydown, not click. + fireEvent.keyDown(screen.getByLabelText('More share options'), { + key: 'Enter', + }); + + expect(await screen.findByTestId('social-share-X')).toBeInTheDocument(); + expect(await findListCopyEntry()).toBeInTheDocument(); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('copies the link from the list entry too', async () => { + renderComponent(); + + fireEvent.keyDown(screen.getByLabelText('More share options'), { + key: 'Enter', + }); + await act(async () => { + fireEvent.click(await findListCopyEntry()); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + }); + + it('opens the native share sheet on a single tap on mobile', async () => { + useViewSizeMock.mockReturnValue(false); + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share, maxTouchPoints: 2 }); + + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link')); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ text: `${text}\n${link}` }), + ); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/share/EntityShareAction.tsx b/packages/shared/src/components/share/EntityShareAction.tsx new file mode 100644 index 00000000000..e45ea9104c7 --- /dev/null +++ b/packages/shared/src/components/share/EntityShareAction.tsx @@ -0,0 +1,82 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { ShareActionsVariant } from './ShareActions'; +import { ShareActions } from './ShareActions'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import { Divider } from '../utilities/Divider'; +import { useLogContext } from '../../contexts/LogContext'; +import type { LogEvent, Origin } from '../../lib/log'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import type { ShareProvider } from '../../lib/share'; + +export interface EntityShareActionProps { + link: string; + text: string; + cid: ReferralCampaignKey; + /** Entity-specific share event, e.g. `LogEvent.ShareTag`. */ + event: LogEvent; + targetId: string; + origin: Origin; + /** + * `split` (default) is the same labelled copy control the end-of-conversation + * band ships: the label copies, the chevron drops the share list. `icon` is + * the original icon-only trigger. + */ + display?: Extract; +} + +/** + * Share control for an entity header (tag / source), promoting share out of the + * "…" options menu. It reads as "Copy link" with a chevron: the primary intent + * is named and one click away, and the chevron says there is more than one way + * to share. + * + * Rendered as a fragment so the host action row's own gap does the spacing. The + * icon-only display keeps a leading vertical rule, which is what separates an + * unlabelled ghost icon from the filled Follow/Following button; the labelled + * control carries that separation in its own label and needs no rule. + */ +export function EntityShareAction({ + link, + text, + cid, + event, + targetId, + origin, + display = 'split', +}: EntityShareActionProps): ReactElement { + const { logEvent } = useLogContext(); + + const onShare = (provider: ShareProvider) => + logEvent({ + event_name: event, + target_id: targetId, + extra: JSON.stringify({ provider, origin }), + }); + + const isIcon = display === 'icon'; + + return ( + <> + {isIcon && ( + // `self-center` because host rows don't all set `items-center`. + + )} + + + ); +} diff --git a/packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx b/packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx new file mode 100644 index 00000000000..24d15451363 --- /dev/null +++ b/packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { SourceActions } from './index'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Source } from '../../../graphql/sources'; +import { SourceType } from '../../../graphql/sources'; +import { useSourceActions } from '../../../hooks'; +import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference'; + +jest.mock('next/router', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('../../../hooks/source/useSourceActions', () => ({ + __esModule: true, + useSourceActions: jest.fn(), + default: jest.fn(), +})); + +jest.mock('../../../hooks/contentPreference/useContentPreference', () => ({ + useContentPreference: jest.fn(), +})); + +const useSourceActionsMock = useSourceActions as jest.Mock; +const useContentPreferenceMock = useContentPreference as jest.Mock; + +const source: Source = { + id: 'tds', + name: 'Towards Data Science', + handle: 'tds', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/tds', + type: SourceType.Machine, + public: true, +}; + +beforeEach(() => { + jest.clearAllMocks(); + useSourceActionsMock.mockReturnValue({ + isBlocked: false, + toggleBlock: jest.fn(), + isFollowing: false, + toggleFollow: jest.fn(), + haveNotificationsOn: false, + toggleNotify: jest.fn(), + }); + useContentPreferenceMock.mockReturnValue({ + follow: jest.fn(), + unfollow: jest.fn(), + }); +}); + +const renderComponent = ({ + showShare = true, + isFollowing = false, + isBlocked = false, +} = {}): RenderResult => { + useSourceActionsMock.mockReturnValue({ + isBlocked, + toggleBlock: jest.fn(), + isFollowing, + toggleFollow: jest.fn(), + haveNotificationsOn: false, + toggleNotify: jest.fn(), + }); + + return render( + + + , + ); +}; + +// Radix's trigger opens on pointerdown/keydown, not click; keyboard also covers +// the a11y requirement for the menu. +const openOptionsMenu = () => + fireEvent.keyDown(screen.getByLabelText('Options'), { key: 'Enter' }); + +describe('SourceActions header treatment', () => { + it('surfaces the copy control next to Follow', async () => { + renderComponent(); + + expect(await screen.findByLabelText('Copy link')).toBeInTheDocument(); + expect(screen.getAllByText('Follow').length).toBeGreaterThan(0); + }); + + it('keeps the control next to Following, alongside the bell', async () => { + renderComponent({ isFollowing: true }); + + expect(await screen.findByLabelText('Copy link')).toBeInTheDocument(); + expect(screen.getAllByText('Following').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Enable notifications')).toBeInTheDocument(); + }); + + it('drops the in-menu share entry and moves Block into the menu', async () => { + renderComponent(); + + expect(screen.queryByTestId('blockButton')).not.toBeInTheDocument(); + + openOptionsMenu(); + + expect(await screen.findByText('Add to custom feed')).toBeInTheDocument(); + expect(screen.getByText('Block')).toBeInTheDocument(); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); + + it('keeps Unblock in the row while blocked, and out of the menu', async () => { + renderComponent({ isBlocked: true }); + + expect(await screen.findByLabelText('Unblock')).toBeInTheDocument(); + + openOptionsMenu(); + + expect(await screen.findByText('Add to custom feed')).toBeInTheDocument(); + expect(screen.queryByText('Block')).not.toBeInTheDocument(); + }); + + it('leaves embedded consumers on the original row', async () => { + renderComponent({ showShare: false }); + + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + expect(screen.getByTestId('blockButton')).toBeInTheDocument(); + + openOptionsMenu(); + + expect(await screen.findByText('Share')).toBeInTheDocument(); + expect(screen.getByText('Add to custom feed')).toBeInTheDocument(); + // Block stays the row button it has always been, not a menu entry. + expect(screen.getAllByText('Block')).toHaveLength(1); + }); +}); diff --git a/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx b/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx index dfbec3ffcdb..67a7ea270f2 100644 --- a/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx +++ b/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx @@ -9,16 +9,24 @@ interface SourceActionsNotifyProps { haveNotificationsOn: boolean; onClick: (e: React.MouseEvent) => void; disabled?: boolean; + /** Overrides the state-derived variant when the host row wants one treatment + * across every secondary control. */ + variant?: ButtonVariant; } const SourceActionsNotify = (props: SourceActionsNotifyProps): ReactElement => { - const { haveNotificationsOn, onClick, disabled } = props; + const { + haveNotificationsOn, + onClick, + disabled, + variant: variantProp, + } = props; const icon = haveNotificationsOn ? : ; const label = `${haveNotificationsOn ? 'Disable' : 'Enable'} notifications`; - const variant = haveNotificationsOn - ? ButtonVariant.Subtle - : ButtonVariant.Secondary; + const variant = + variantProp ?? + (haveNotificationsOn ? ButtonVariant.Subtle : ButtonVariant.Secondary); return ( diff --git a/packages/shared/src/components/sources/SourceActions/index.tsx b/packages/shared/src/components/sources/SourceActions/index.tsx index 266ef8d4c31..433c6fbe774 100644 --- a/packages/shared/src/components/sources/SourceActions/index.tsx +++ b/packages/shared/src/components/sources/SourceActions/index.tsx @@ -8,9 +8,12 @@ import SourceActionsNotify from './SourceActionsNotify'; import SourceActionsBlock from './SourceActionsBlock'; import SourceActionsFollow from './SourceActionsFollow'; import CustomFeedOptionsMenu from '../../CustomFeedOptionsMenu'; -import { LogEvent } from '../../../lib/log'; +import { LogEvent, Origin } from '../../../lib/log'; import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference'; import { ContentPreferenceType } from '../../../graphql/contentPreference'; +import { EntityShareAction } from '../../share/EntityShareAction'; +import { MenuIcon } from '../../MenuIcon'; +import { BlockIcon } from '../../icons'; interface SourceActionsButton { className?: string; @@ -25,6 +28,12 @@ export interface SourceActionsProps { hideFollow?: boolean; notifyProps?: SourceActionsButton; hideNotify?: boolean; + /** + * Opt in to the header treatment: the visible share control, Block moved into + * the "…" menu and the bell restyled to match. Off by default so embedded + * consumers, e.g. the post page highlights widget, keep their existing row. + */ + showShare?: boolean; } export const SourceActions = ({ @@ -35,6 +44,7 @@ export const SourceActions = ({ hideNotify = false, source, notifyProps, + showShare = false, }: SourceActionsProps): ReactElement => { const { isBlocked, @@ -49,6 +59,46 @@ export const SourceActions = ({ const { follow, unfollow } = useContentPreference(); const router = useRouter(); + // `Source['id']` is optional on the model; the handle is the stable fallback + // every consumer of this row can rely on. + const sourceId = source.id ?? source.handle; + + const shareProps = { + text: `Check out ${source.handle} on daily.dev`, + link: source.permalink, + cid: ReferralCampaignKey.ShareSource, + }; + + const canBlock = !hideBlock && !isFollowing; + // With the share control in the row, Block moves into the "…" menu so the row + // is one Follow button plus identical secondary controls. Blocked is the + // exception: Unblock is the only way back, so it stays visible — in the + // Follow slot, which is empty while blocked. + const showBlockInRow = showShare ? canBlock && isBlocked : canBlock; + const blockOptions = + showShare && canBlock && !isBlocked + ? [ + { + icon: , + label: 'Block', + action: toggleBlock, + }, + ] + : []; + + // Sits before the block button in the original row and after the share + // control in the header row, so it is defined once and placed twice. + const notifyButton = !hideNotify && isFollowing && ( + + ); + return (
{!hideFollow && !isBlocked && ( @@ -60,21 +110,26 @@ export const SourceActions = ({ {...followProps} /> )} - {!hideNotify && isFollowing && ( - - )} - {!hideBlock && !isFollowing && ( + {!showShare && notifyButton} + {showBlockInRow && ( )} + {showShare && ( + + )} + {showShare && notifyButton} router.push( `/feeds/new?entityId=${source.id}&entityType=${ContentPreferenceType.Source}`, @@ -82,7 +137,7 @@ export const SourceActions = ({ } onAdd={(feedId) => follow({ - id: source.id, + id: sourceId, entity: ContentPreferenceType.Source, entityName: source.handle, feedId, @@ -90,16 +145,14 @@ export const SourceActions = ({ } onUndo={(feedId) => unfollow({ - id: source.id, + id: sourceId, entity: ContentPreferenceType.Source, entityName: source.handle, feedId, }) } shareProps={{ - text: `Check out ${source.handle} on daily.dev`, - link: source.permalink, - cid: ReferralCampaignKey.ShareSource, + ...shareProps, logObject: () => ({ event_name: LogEvent.ShareSource, target_id: source.id, diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index 3bd8c235d6a..d83c2fe97ac 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -67,6 +67,8 @@ import { TypographyTag, TypographyType, } from '../typography/Typography'; +import { EntityShareAction } from '../share/EntityShareAction'; +import { MenuIcon } from '../MenuIcon'; const SUPPORTED_TYPES = [ PostType.Article, @@ -307,22 +309,41 @@ export const TagTopicPage = ({ }, }; - const blockButtonProps: ButtonProps<'button'> = { - size: ButtonSize.Small, - icon: tagStatus === 'blocked' ? : , - onClick: async (): Promise => { - if (!user) { - showLogin({ trigger: AuthTriggers.Filter }); - return; - } - if (tagStatus === 'blocked') { - await onUnblockTags({ tags: [tag] }); - } else { - await onBlockTags({ tags: [tag] }); - } - }, + // Shared by the row's Unblock button and the "…" menu's Block entry. + const onToggleBlock = async (): Promise => { + if (!user) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + if (tagStatus === 'blocked') { + await onUnblockTags({ tags: [tag] }); + } else { + await onBlockTags({ tags: [tag] }); + } }; + // Shared by the visible share control and the "…" menu entry so both always + // hand out the same link — `location.href` is the tag page itself. + const tagShareProps = { + text: `Check out the ${tag} tag on daily.dev`, + link: globalThis?.location?.href, + cid: ReferralCampaignKey.ShareTag, + }; + + // Block moves into the "…" menu so the row is one Follow button plus + // identical secondary controls. Blocked is the exception: Unblock is the only + // way back, so it stays visible — in the Follow slot, which is empty then. + const blockOptions = + tagStatus === 'unfollowed' + ? [ + { + icon: , + label: 'Block', + action: onToggleBlock, + }, + ] + : []; + const statParts: ReactNode[] = []; if (typeof followers === 'number') { statParts.push( @@ -398,16 +419,26 @@ export const TagTopicPage = ({ {tagStatus === 'followed' ? 'Following' : 'Follow'} )} - {tagStatus !== 'followed' && ( + {tagStatus === 'blocked' && ( )} + push( `${webappUrl}feeds/new?entityId=${tag}&entityType=${ContentPreferenceType.Keyword}`, @@ -430,9 +461,7 @@ export const TagTopicPage = ({ }) } shareProps={{ - text: `Check out the ${tag} tag on daily.dev`, - link: globalThis?.location?.href, - cid: ReferralCampaignKey.ShareTag, + ...tagShareProps, logObject: () => ({ event_name: LogEvent.ShareTag, target_id: tag, diff --git a/packages/storybook/stories/components/EntityShareAction.stories.tsx b/packages/storybook/stories/components/EntityShareAction.stories.tsx new file mode 100644 index 00000000000..86995524d1c --- /dev/null +++ b/packages/storybook/stories/components/EntityShareAction.stories.tsx @@ -0,0 +1,705 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import { EntityShareAction } from '@dailydotdev/shared/src/components/share/EntityShareAction'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import CustomFeedOptionsMenu from '@dailydotdev/shared/src/components/CustomFeedOptionsMenu'; +import { MenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; +import SourceActionsFollow from '@dailydotdev/shared/src/components/sources/SourceActions/SourceActionsFollow'; +import SourceActionsNotify from '@dailydotdev/shared/src/components/sources/SourceActions/SourceActionsNotify'; +import SourceActionsBlock from '@dailydotdev/shared/src/components/sources/SourceActions/SourceActionsBlock'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + BellAddIcon, + BellSubscribedIcon, + BlockIcon, + MiniCloseIcon as XIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { GrowthBookProvider } from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; + +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 SHORT_LINK = 'https://dly.to/abc123'; + +type Entity = 'tag' | 'source'; + +/** + * Tag page: `default` shows Follow + Block, `following` drops Block, `blocked` + * drops Follow. The source page follows the same three-way split. + */ +type EntityState = 'default' | 'following' | 'blocked'; + +const entityConfig: Record< + Entity, + { + link: string; + text: string; + cid: ReferralCampaignKey; + event: LogEvent; + targetId: string; + origin: Origin; + } +> = { + tag: { + link: 'https://app.daily.dev/tags/webdev', + text: 'Check out the webdev tag on daily.dev', + cid: ReferralCampaignKey.ShareTag, + event: LogEvent.ShareTag, + targetId: 'webdev', + origin: Origin.TagPage, + }, + source: { + link: 'https://app.daily.dev/sources/tds', + text: 'Check out tds on daily.dev', + cid: ReferralCampaignKey.ShareSource, + event: LogEvent.ShareSource, + targetId: 'tds', + origin: Origin.SourcePage, + }, +}; + +/** + * `current` is the row as it was before this change: a Block button in the row + * and an icon-only share behind a vertical rule. Kept for comparison only. + * `proposed` is what both pages ship now — Block in the "…" menu, a labelled + * "Copy link" with a chevron, and the bell styled like the "…" button. + */ +type Layout = 'current' | 'proposed'; + +const menuProps = ( + entity: Entity, + { layout, state }: { layout: Layout; state: EntityState }, +) => ({ + onAdd: fn(), + onUndo: fn(), + onCreateNewFeed: fn(), + shareProps: { + text: entityConfig[entity].text, + link: entityConfig[entity].link, + cid: entityConfig[entity].cid, + }, + // Block leaves the row and becomes a menu entry in the proposed layout — + // except while blocked, where Unblock stays in the row as the visible way out + // of a state the user did not necessarily mean to be in. Burying the only + // escape behind "…" is what makes a blocked entity feel stuck. + additionalOptions: + layout === 'proposed' && state !== 'blocked' + ? [ + { + icon: , + label: 'Block', + action: fn(), + }, + ] + : [], +}); + +// Every secondary control in the row is the same button: Float, size Small, +// icon-only. The bell is the only one that also carries a state. +const RowIconButton = ({ + icon, + label, +}: { + icon: ReactElement; + label: string; +}): ReactElement => ( + + )} + {/* Blocked keeps Unblock in the row in both layouts — it is the only way + back, so it never goes behind the "…" menu. */} + {(state === 'blocked' || (isCurrent && state !== 'following')) && ( + + )} + + +
+ ); +}; + +interface SourceActionRowProps extends RowProps { + /** Only reachable while following: the bell toggles subscribed / not. */ + notificationsOn?: boolean; + /** `/sources/[source]` passes `showShare`; embedded consumers don't. */ + showShare?: boolean; +} + +// Mirrors `SourceActions` (rendered inside `PageInfoHeader` on the source page): +// same child components, same `gap-2`, same show/hide rules. +const SourceActionRow = ({ + state, + notificationsOn = false, + showShare = true, + layout = 'proposed', +}: SourceActionRowProps): ReactElement => { + const isCurrent = layout === 'current'; + + return ( +
+ {state !== 'blocked' && ( + + )} + {isCurrent && state === 'following' && ( + + )} + {/* Blocked keeps Unblock in the row in both layouts — it is the only way + back, so it never goes behind the "…" menu. In the proposed layout it + takes the Follow slot, which is empty while blocked. */} + {(state === 'blocked' || (isCurrent && state !== 'following')) && ( + + )} + {showShare && ( + + )} + {!isCurrent && state === 'following' && ( + : } + label={ + notificationsOn ? 'Disable notifications' : 'Enable notifications' + } + /> + )} + +
+ ); +}; + +const Section = ({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}): ReactElement => ( +
+
+ + {title} + + + {description} + +
+
{children}
+
+); + +const Case = ({ + label, + note, + wide = false, + children, +}: { + label: string; + note?: string; + /** Cases whose content is wider than a single action row. */ + wide?: boolean; + children: ReactNode; +}): ReactElement => ( +
+ + {label} + +
+ {children} +
+ {note && ( + + {note} + + )} +
+); + +const INTERACTION_CHECKS = [ + 'Click the “Copy link” label — it copies straight away and the copy glyph cross-fades into a green check for a second.', + 'Click the chevron — the DS dropdown opens with the social tiles and the chevron rotates to point back at it.', + 'Hover the two halves — one seam, not two borders meeting, and the whole control keeps a standard button’s height and radius.', + 'Hover the three secondary controls (copy link, bell, “…”) — same Float surface, same size, same hover.', + 'Open the “…” menu — Block lives there, under “Add to custom feed” (additionalOptions append; say the word if it should sit first).', + 'Block a tag or source — Unblock comes back out into the row and leaves the menu, so the way back is never buried.', + 'Switch the toolbar viewport to a mobile size — the chevron and the dropdown drop; a single tap opens the native share sheet.', + 'Toggle the toolbar theme — the row and the dropdown have to hold contrast in dark mode.', +]; + +// Every state the promoted share control can be seen in, on one page. +const Overview = (): ReactElement => ( +
+
+ + + + + + +
+ +
+ + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+ + + + +
+ +
+
+ + + + +
+ + {[ + ButtonVariant.Tertiary, + ButtonVariant.Float, + ButtonVariant.Subtle, + ].map((variant) => ( + + ))} +
+
+
+ +
+
    + {INTERACTION_CHECKS.map((item) => ( +
  • + + {item} + +
  • + ))} +
+
+
+); + +interface PlaygroundProps { + entity: Entity; + state: EntityState; + notificationsOn: boolean; + layout: Layout; +} + +const Playground = ({ + entity, + state, + notificationsOn, + layout, +}: PlaygroundProps): ReactElement => + entity === 'tag' ? ( + + ) : ( + + ); + +const withProviders = (Story: React.ComponentType): ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + // Seed the resolved short URL for both campaigns so nothing hits network. + [ReferralCampaignKey.ShareTag, ReferralCampaignKey.ShareSource].forEach( + (cid) => { + Object.values(entityConfig).forEach((config) => { + const { queryKey } = getShortLinkProps(config.link, cid, mockUser); + queryClient.setQueryData(queryKey, SHORT_LINK); + }); + }, + ); + + const LogContext = getLogContextStatic(); + + return ( + + + + false, + }} + > +
+ +
+
+
+
+
+ ); +}; + +const meta: Meta = { + title: 'Components/Share/EntityShareAction', + component: Playground, + decorators: [withProviders], + parameters: { + docs: { + description: { + component: + 'Promotes share out of the tag/source "…" options menu into the header action row as a labelled "Copy link" button whose chevron opens the full share list (the SplitShareButton from PR #6369). Block moves into the "…" menu — except while blocked, where Unblock stays in the row. Ships to everyone: no feature flag.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Every surface, state and flag combination on one page. Start here. */ +export const AllStates: StoryObj = { + render: () => , + parameters: { controls: { disable: true } }, +}; + +/** Drive a single row from the controls panel. */ +export const InteractivePlayground: Story = { + args: { + entity: 'tag', + state: 'default', + notificationsOn: false, + layout: 'proposed', + }, + argTypes: { + entity: { control: 'inline-radio', options: ['tag', 'source'] }, + state: { + control: 'inline-radio', + options: ['default', 'following', 'blocked'], + }, + layout: { + control: 'inline-radio', + options: ['proposed', 'current'], + description: + 'proposed = Copy link ▾ with Block in the menu; current = what the PR ships', + }, + notificationsOn: { + control: 'boolean', + description: 'Source page only, and only while following', + }, + }, +}; + +export const TagDefault: StoryObj = { + render: () => , +}; + +export const TagFollowing: StoryObj = { + render: () => , +}; + +export const TagBlocked: StoryObj = { + render: () => , +}; + +export const SourceDefault: StoryObj = { + render: () => , +}; + +export const SourceFollowing: StoryObj = { + render: () => , +}; + +/** Densest row: Following + Copy link ▾ + bell + "…". */ +export const SourceFollowingWithNotifications: StoryObj = { + render: () => , +}; + +/** The row as the PR ships it today, for side-by-side comparison. */ +export const CurrentLayout: StoryObj = { + render: () => ( + + ), +}; + +export const SourceBlocked: StoryObj = { + render: () => , +}; + +/** + * `SourceActions` is also rendered on the post page without `showShare`, so it + * keeps the row it has today: Block as a button, Share inside the "…" menu. + */ +export const EmbeddedConsumerOptedOut: StoryObj = { + render: () => , +}; + +/** Mobile: a single tap opens the native share sheet instead of the popover. */ +export const TagMobile: StoryObj = { + render: () => , + globals: { viewport: { value: 'mobile1' } }, +}; + +/** The popover's contents, rendered inline. */ +export const SharePopoverContents: StoryObj = { + render: () => ( + + ), +}; diff --git a/packages/webapp/__tests__/SourcePage.tsx b/packages/webapp/__tests__/SourcePage.tsx index 7685244bb8f..1b10e099912 100644 --- a/packages/webapp/__tests__/SourcePage.tsx +++ b/packages/webapp/__tests__/SourcePage.tsx @@ -8,7 +8,13 @@ import nock from 'nock'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import React from 'react'; import type { RenderResult } from '@testing-library/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; import type { NextRouter } from 'next/router'; @@ -213,14 +219,40 @@ it('should show notify button if following', async () => { expect(notifyButton).toBeInTheDocument(); }); -it('should show block button', async () => { +// The feed cards carry their own options menus, so the header's is found via +// the action row. Radix's trigger opens on pointerdown/keydown, not click. +const findActionRow = async (): Promise => { + const anchor = await screen.findByLabelText( + /^(Toggle follow status|Unblock)/, + ); + const row = anchor.parentElement; + if (!row) { + throw new Error('the action row button is not mounted in a row'); + } + return row; +}; + +const openOptionsMenu = async () => { + const row = await findActionRow(); + fireEvent.keyDown(within(row).getByLabelText('Options'), { key: 'Enter' }); +}; + +/** Block left the row for the header's "…" menu. */ +const clickBlockOption = async () => { + await openOptionsMenu(); + fireEvent.click(await screen.findByText('Block')); +}; + +it('should keep block in the options menu', async () => { renderComponent([ createFeedMock(), createSourcesSettingsMock({ excludeSources: [] }), ]); await waitForNock(); - const button = await screen.findByTestId('blockButton'); - expect(button).toBeInTheDocument(); + expect(screen.queryByTestId('blockButton')).not.toBeInTheDocument(); + + await openOptionsMenu(); + expect(await screen.findByText('Block')).toBeInTheDocument(); }); it('should show login popup when logged-out on add to feed click', async () => { @@ -239,8 +271,7 @@ it('should show login popup when logged-out on add to feed click', async () => { null, ); await waitForNock(); - const button = await screen.findByTestId('blockButton'); - button.click(); + await clickBlockOption(); expect(showLogin).toBeCalledTimes(1); }); @@ -264,10 +295,15 @@ it('should activate notify from source', async () => { }, }); const button = await screen.findByTestId('blockButton'); - const initialText = button.textContent; + expect(button).toHaveTextContent('Unblock'); button.click(); await waitFor(() => expect(mutationCalled).toBeTruthy()); - expect(initialText).not.toBe(button.textContent); + // Unblocking sends Block back into the "…" menu and clears the row. + await waitFor(() => + expect(screen.queryByTestId('blockButton')).not.toBeInTheDocument(), + ); + await openOptionsMenu(); + expect(await screen.findByText('Block')).toBeInTheDocument(); }); it('should block source', async () => { @@ -292,9 +328,8 @@ it('should block source', async () => { return { data: { feedSettings: { id: defaultUser.id } } }; }, }); - const button = await screen.findByTestId('blockButton'); - const initialText = button.textContent; - button.click(); + await clickBlockOption(); await waitFor(() => expect(mutationCalled).toBeTruthy()); - expect(initialText).not.toBe(button.textContent); + // Blocking brings Unblock out into the row. + expect(await screen.findByTestId('blockButton')).toHaveTextContent('Unblock'); }); diff --git a/packages/webapp/__tests__/TagPage.tsx b/packages/webapp/__tests__/TagPage.tsx index 1cd77c30559..01e48458597 100644 --- a/packages/webapp/__tests__/TagPage.tsx +++ b/packages/webapp/__tests__/TagPage.tsx @@ -5,7 +5,13 @@ import nock from 'nock'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import React from 'react'; import type { RenderResult } from '@testing-library/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { LoggedUser, @@ -250,13 +256,48 @@ it('should request tag feed', async () => { }); }); -it('should show follow and block buttons', async () => { +// The feed cards carry their own options menus and copy-link buttons, so the +// header's controls are found via the action row they share with Follow. +const findActionRow = async (): Promise => { + const button = await screen.findByLabelText(/^(Follow|Unfollow|Unblock)$/); + const row = button.parentElement; + if (!row) { + throw new Error('the action row button is not mounted in a row'); + } + return row; +}; + +// Radix's trigger opens on pointerdown/keydown, not click. +const openOptionsMenu = async () => { + const row = await findActionRow(); + fireEvent.keyDown(within(row).getByLabelText('Options'), { key: 'Enter' }); +}; + +/** Block left the row for the header's "…" menu. */ +const clickBlockOption = async () => { + await openOptionsMenu(); + fireEvent.click(await screen.findByText('Block')); +}; + +it('should show the follow button, with block in the options menu', async () => { renderComponent(); await waitForNock(); const followButton = await screen.findByLabelText('Follow'); expect(followButton).toBeInTheDocument(); - const blockButton = await screen.findByLabelText('Block'); - expect(blockButton).toBeInTheDocument(); + expect(screen.queryByLabelText('Block')).not.toBeInTheDocument(); + + await openOptionsMenu(); + expect(await screen.findByText('Block')).toBeInTheDocument(); +}); + +it('should show the copy-link control in the header', async () => { + renderComponent(); + await waitForNock(); + // Scoped to the action row — the feed cards carry copy-link buttons too. + // jsdom reports no laptop match, so this is the mobile path: the labelled + // trigger without the chevron, tapping straight through to native share. + const row = await findActionRow(); + expect(within(row).getByLabelText('Copy link')).toBeInTheDocument(); }); it('should show only unfollow button', async () => { @@ -289,8 +330,9 @@ it('should show follow and block buttons when logged-out', async () => { await waitForNock(); const followButton = await screen.findByLabelText('Follow'); expect(followButton).toBeInTheDocument(); - const blockButton = await screen.findByLabelText('Block'); - expect(blockButton).toBeInTheDocument(); + + await openOptionsMenu(); + expect(await screen.findByText('Block')).toBeInTheDocument(); }); it('should show login popup when logged-out on follow click', async () => { @@ -340,8 +382,7 @@ it('should show login popup when logged-out on block click', async () => { null, ); await waitForNock(); - const blockButton = await screen.findByLabelText('Block'); - blockButton.click(); + await clickBlockOption(); expect(showLogin).toBeCalledTimes(1); }); @@ -392,8 +433,7 @@ it('should block tag', async () => { return { data: { feedSettings: { id: defaultUser.id } } }; }, }); - const button = await screen.findByLabelText('Block'); - button.click(); + await clickBlockOption(); await waitFor(() => expect(mutationCalled).toBeTruthy()); await waitFor(async () => { @@ -458,10 +498,12 @@ it('should unblock tag', async () => { const button = await screen.findByLabelText('Unblock'); button.click(); await waitFor(() => expect(mutationCalled).toBeTruthy()); + // Unblocking puts Follow back in the row and Block back in the "…" menu. await waitFor(async () => { - const followButton = await screen.findByLabelText('Block'); - expect(followButton).toBeInTheDocument(); + expect(await screen.findByLabelText('Follow')).toBeInTheDocument(); }); + await openOptionsMenu(); + expect(await screen.findByText('Block')).toBeInTheDocument(); }); it('should load title and description for tag', async () => { diff --git a/packages/webapp/__tests__/TagSourceSeo.spec.ts b/packages/webapp/__tests__/TagSourceSeo.spec.ts new file mode 100644 index 00000000000..5cc0d8e1b61 --- /dev/null +++ b/packages/webapp/__tests__/TagSourceSeo.spec.ts @@ -0,0 +1,115 @@ +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { KEYWORD_QUERY } from '@dailydotdev/shared/src/graphql/keywords'; +import { + SOURCE_QUERY, + SourceType, +} from '@dailydotdev/shared/src/graphql/sources'; +import { getStaticProps as getTagStaticProps } from '../pages/tags/[tag]'; +import { getStaticProps as getSourceStaticProps } from '../pages/sources/[source]'; + +jest.mock('@dailydotdev/shared/src/graphql/common', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); + + return { ...actual, gqlClient: { request: jest.fn() } }; +}); + +const request = gqlClient.request as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// Share links are only worth promoting if the unfurl names the entity, so lock +// the per-entity title/description that `next-seo` turns into og:title and +// og:description (it falls back to `description` when `openGraph.description` +// is unset). og:url/canonical come from `_app`'s DefaultSeo, per route. +describe('tag page SEO', () => { + it('describes the specific tag', async () => { + request.mockImplementation((query) => { + if (query === KEYWORD_QUERY) { + return Promise.resolve({ + keyword: { + value: 'webdev', + flags: { + title: 'Web Development', + description: 'Everything about building for the web.', + }, + }, + }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getTagStaticProps({ params: { tag: 'webdev' } }); + + expect('props' in result && result.props.seo).toMatchObject({ + title: 'Web Development posts | daily.dev', + description: 'Everything about building for the web.', + openGraph: { title: 'Web Development posts | daily.dev' }, + }); + }); + + it('falls back to a generated description when the tag has none', async () => { + request.mockImplementation((query) => { + if (query === KEYWORD_QUERY) { + return Promise.resolve({ keyword: { value: 'webdev', flags: {} } }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getTagStaticProps({ params: { tag: 'webdev' } }); + + expect('props' in result && result.props.seo?.description).toEqual( + 'Find all the recent posts, videos, updates and discussions about webdev', + ); + }); +}); + +describe('source page SEO', () => { + const source = { + id: 'tds', + name: 'Towards Data Science', + handle: 'tds', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/tds', + type: SourceType.Machine, + public: true, + description: 'Sharing concepts, ideas and codes.', + }; + + it('describes the specific source', async () => { + request.mockImplementation((query) => { + if (query === SOURCE_QUERY) { + return Promise.resolve({ source }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getSourceStaticProps({ params: { source: 'tds' } }); + + expect('props' in result && result.props.seo).toMatchObject({ + title: 'Towards Data Science posts | daily.dev', + description: 'Sharing concepts, ideas and codes.', + openGraph: { title: 'Towards Data Science posts | daily.dev' }, + }); + }); + + it('keeps the generic description when the source has none', async () => { + request.mockImplementation((query) => { + if (query === SOURCE_QUERY) { + return Promise.resolve({ source: { ...source, description: null } }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getSourceStaticProps({ params: { source: 'tds' } }); + + expect('props' in result && result.props.seo?.description).toContain( + 'daily.dev is the easiest way', + ); + }); +}); diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index 74a0f3958cb..bd5d323c051 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -291,7 +291,7 @@ const SourcePage = ({

{source.name}

- +
{source?.description && (

{source?.description}

From 5909b5943339992b9af296e0f61bcbc5944ddca1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 14:43:32 +0300 Subject: [PATCH 2/2] fix(share): attribute and encode the individual social share links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy path tags its link with the referral campaign through `useShareOrCopyLink`, but the social targets went out untagged: `SocialShareList` called `getShortUrl(link)` with no cid. The post share modal was unaffected — it pre-tags the link before handing it down — so this only hit `ShareActions` consumers, which is the control this PR surfaces. `SocialShareList` now takes an optional `cid` and `ShareActions` passes its own. Two link builders also interpolated their text raw, so any `&`, `#`, `?` or `%` in a title silently truncated or corrupted the shared text: - `getRedditShareLink` — `&title=` now encoded - `getTelegramShareLink` — `&text=` now encoded And `getWhatsappShareLink` shared a bare URL while every other target carried the description; it now sends the text with the link. Facebook and LinkedIn are left alone — both ignore text/quote params. Co-Authored-By: Claude Opus 5 --- .../components/share/ShareActions.spec.tsx | 45 +++++++++++++++++ .../src/components/share/ShareActions.tsx | 3 ++ .../components/widgets/SocialShareList.tsx | 9 +++- packages/shared/src/lib/share.spec.ts | 50 +++++++++++++++++-- packages/shared/src/lib/share.ts | 14 ++++-- 5 files changed, 112 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/share/ShareActions.spec.tsx b/packages/shared/src/components/share/ShareActions.spec.tsx index 5a86a8de95c..b294b875ede 100644 --- a/packages/shared/src/components/share/ShareActions.spec.tsx +++ b/packages/shared/src/components/share/ShareActions.spec.tsx @@ -10,7 +10,10 @@ import { import { QueryClient } from '@tanstack/react-query'; import { ShareActions } from './ShareActions'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import defaultUser from '../../../__tests__/fixture/loggedUser'; +import { getShortLinkProps } from '../../hooks/utils/useGetShortUrl'; import { ShareProvider } from '../../lib/share'; +import { ReferralCampaignKey } from '../../lib/referral'; import { useViewSize } from '../../hooks/useViewSize'; jest.mock('../../hooks/useViewSize', () => { @@ -32,6 +35,8 @@ beforeEach(() => { }); }); +const SHORT_LINK = 'https://dly.to/tagged'; + const renderComponent = ( props: Partial[0]> = {}, ): RenderResult => { @@ -64,6 +69,46 @@ describe('ShareActions inline variant', () => { }); }); +describe('ShareActions social targets', () => { + it('tags the shared link with the campaign, like the copy path', async () => { + const open = jest.fn(); + Object.assign(window, { open }); + + // `getShortUrl` keys its cache off the *tagged* url, so seeding that key + // and seeing the value come back proves the cid reached the social path. + // Without it the lookup misses and nothing resolves. + const client = new QueryClient(); + const { queryKey } = getShortLinkProps( + link, + ReferralCampaignKey.ShareTag, + defaultUser, + ); + client.setQueryData(queryKey, SHORT_LINK); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('social-share-X')); + }); + + await waitFor(() => expect(open).toHaveBeenCalled()); + expect(open.mock.calls[0][0]).toContain(encodeURIComponent(SHORT_LINK)); + }); +}); + describe('ShareActions icon variant on mobile', () => { beforeEach(() => useViewSizeMock.mockReturnValue(false)); diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx index a375ea9caf7..07b34d34f58 100644 --- a/packages/shared/src/components/share/ShareActions.tsx +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -87,6 +87,9 @@ export function ShareActions({ { link, text, }); - expect(result).toEqual(`https://wa.me/?text=${encodeURIComponent(link)}`); + expect(result).toEqual( + `https://wa.me/?text=${encodeURIComponent(`${text}\n${link}`)}`, + ); }); it('should return Twitter share link', () => { @@ -46,7 +48,9 @@ describe('getShareLink tests', () => { text, }); expect(result).toEqual( - `https://reddit.com/submit?url=${encodeURIComponent(link)}&title=${text}`, + `https://reddit.com/submit?url=${encodeURIComponent( + link, + )}&title=${encodeURIComponent(text)}`, ); }); @@ -69,7 +73,9 @@ describe('getShareLink tests', () => { text, }); expect(result).toEqual( - `https://t.me/share/url?url=${encodeURIComponent(link)}&text=${text}`, + `https://t.me/share/url?url=${encodeURIComponent( + link, + )}&text=${encodeURIComponent(text)}`, ); }); @@ -100,6 +106,44 @@ describe('getShareLink tests', () => { )}`, ); }); + + // Post titles routinely carry `&`, `#` and `?`, which silently truncate or + // corrupt the shared text when interpolated raw. + const unsafeText = 'Rust & Go: what #1 teams ask? 100% real'; + + it('should encode Reddit titles containing URL characters', () => { + const result = getShareLink({ + provider: ShareProvider.Reddit, + link, + text: unsafeText, + }); + expect(result).toEqual( + `https://reddit.com/submit?url=${encodeURIComponent( + link, + )}&title=${encodeURIComponent(unsafeText)}`, + ); + expect(new URL(result).searchParams.get('title')).toEqual(unsafeText); + }); + + it('should encode Telegram text containing URL characters', () => { + const result = getShareLink({ + provider: ShareProvider.Telegram, + link, + text: unsafeText, + }); + expect(new URL(result).searchParams.get('text')).toEqual(unsafeText); + }); + + it('should carry the text into the WhatsApp message', () => { + const result = getShareLink({ + provider: ShareProvider.WhatsApp, + link, + text: unsafeText, + }); + expect(new URL(result).searchParams.get('text')).toEqual( + `${unsafeText}\n${link}`, + ); + }); }); describe('addLogQueryParams tests', () => { diff --git a/packages/shared/src/lib/share.ts b/packages/shared/src/lib/share.ts index b6bb78125b7..89dad42a04c 100644 --- a/packages/shared/src/lib/share.ts +++ b/packages/shared/src/lib/share.ts @@ -12,8 +12,8 @@ export enum ShareProvider { Email = 'email', } -export const getWhatsappShareLink = (link: string): string => - `https://wa.me/?text=${encodeURIComponent(link)}`; +export const getWhatsappShareLink = (link: string, text?: string): string => + `https://wa.me/?text=${encodeURIComponent(text ? `${text}\n${link}` : link)}`; export const getTwitterShareLink = (link: string, text: string): string => `http://twitter.com/share?url=${encodeURIComponent( link, @@ -23,13 +23,17 @@ export const getFacebookShareLink = (link: string): string => link, )}`; export const getRedditShareLink = (link: string, text: string): string => - `https://reddit.com/submit?url=${encodeURIComponent(link)}&title=${text}`; + `https://reddit.com/submit?url=${encodeURIComponent( + link, + )}&title=${encodeURIComponent(text)}`; export const getLinkedInShareLink = (link: string): string => `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent( link, )}`; export const getTelegramShareLink = (link: string, text: string): string => - `https://t.me/share/url?url=${encodeURIComponent(link)}&text=${text}`; + `https://t.me/share/url?url=${encodeURIComponent( + link, + )}&text=${encodeURIComponent(text)}`; export const getEmailShareLink = ( link: string, subject: string, @@ -57,7 +61,7 @@ export const getShareLink = ({ }: GetShareLinkParams): string => { switch (provider) { case ShareProvider.WhatsApp: - return getWhatsappShareLink(link); + return getWhatsappShareLink(link, text); case ShareProvider.Twitter: return getTwitterShareLink(link, text); case ShareProvider.Facebook: