Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions packages/shared/src/components/CustomFeedOptionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -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: {
Expand All @@ -59,11 +62,15 @@ const CustomFeedOptionsMenu = ({
};

const options: MenuItemProps[] = [
{
icon: <MenuIcon Icon={ShareIcon} />,
label: 'Share',
action: () => onShareOrCopyLink(),
},
...(hideShare
? []
: [
{
icon: <MenuIcon Icon={ShareIcon} />,
label: 'Share',
action: () => onShareOrCopyLink(),
},
]),
{
icon: <MenuIcon Icon={HashtagIcon} />,
label: 'Add to custom feed',
Expand Down
155 changes: 155 additions & 0 deletions packages/shared/src/components/share/EntityShareAction.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TestBootProvider client={client} log={{ logEvent }}>
<EntityShareAction
link={link}
text={text}
cid={ReferralCampaignKey.ShareTag}
event={LogEvent.ShareTag}
targetId="webdev"
origin={Origin.TagPage}
display={display}
/>
</TestBootProvider>,
);

const getToast = () =>
client.getQueryData<ToastNotification>(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();
});
});
82 changes: 82 additions & 0 deletions packages/shared/src/components/share/EntityShareAction.tsx
Original file line number Diff line number Diff line change
@@ -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<ShareActionsVariant, 'icon' | 'split'>;
}

/**
* 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`.
<Divider vertical className="self-center" />
)}
<ShareActions
link={link}
text={text}
cid={cid}
variant={display}
label={isIcon ? 'Share' : 'Copy link'}
triggerText={isIcon ? undefined : 'Copy link'}
emailTitle={text}
emailSummary={text}
// Float matches the bell and the "…" button, so every secondary control
// in the host row reads as the same button.
buttonVariant={isIcon ? ButtonVariant.Tertiary : ButtonVariant.Float}
buttonSize={ButtonSize.Small}
onShare={onShare}
/>
</>
);
}
45 changes: 45 additions & 0 deletions packages/shared/src/components/share/ShareActions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -32,6 +35,8 @@ beforeEach(() => {
});
});

const SHORT_LINK = 'https://dly.to/tagged';

const renderComponent = (
props: Partial<Parameters<typeof ShareActions>[0]> = {},
): RenderResult => {
Expand Down Expand Up @@ -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(
<TestBootProvider
client={client}
auth={{ user: defaultUser, isAuthReady: true }}
>
<ShareActions
link={link}
text={text}
onShare={onShare}
variant="inline"
cid={ReferralCampaignKey.ShareTag}
/>
</TestBootProvider>,
);

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));

Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/components/share/ShareActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export function ShareActions({
<SocialShareList
link={link}
description={text}
// The copy path tags the link through `useShareOrCopyLink`; the social
// targets need the same campaign or their traffic lands unattributed.
cid={cid}
emailTitle={emailTitle}
emailSummary={emailSummary}
isCopying={copying}
Expand Down
Loading
Loading