Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "storybook",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "storybook", "dev"],
"port": 6006
}
]
}
6 changes: 4 additions & 2 deletions packages/shared/src/components/ShareMobile.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ReactElement } from 'react';
import React, { useContext } from 'react';
import { LinkIcon, ShareIcon } from './icons';
import { CopyIcon, LinkIcon, ShareIcon } from './icons';
import { useCopyPostLink } from '../hooks/useCopyPostLink';
import { useShareCopyIcon } from '../hooks/useShareCopyIcon';
import {
Button,
ButtonColor,
Expand Down Expand Up @@ -35,6 +36,7 @@ export function ShareMobile({
const { openSharePost } = useSharePost(origin);
const { logEvent } = useLogContext();
const { logOpts } = useContext(ActiveFeedContext);
const showCopyIcon = useShareCopyIcon();

const onShare = () => {
logEvent(
Expand All @@ -51,7 +53,7 @@ export function ShareMobile({
size={ButtonSize.Small}
onClick={onCopyPostLink}
pressed={copying}
icon={<LinkIcon />}
icon={showCopyIcon ? <CopyIcon secondary={copying} /> : <LinkIcon />}
variant={ButtonVariant.Tertiary}
color={ButtonColor.Avocado}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CardActionBar } from '../../buttons/CardActionBar';
import {
AnalyticsIcon,
DiscussIcon as CommentIcon,
CopyIcon,
LinkIcon,
DownvoteIcon,
} from '../../icons';
Expand All @@ -21,6 +22,7 @@ import { PostTagsPanel } from '../../post/block/PostTagsPanel';
import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip';
import { useCardActions } from '../../../hooks/cards/useCardActions';
import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship';
import { useShareCopyIcon } from '../../../hooks/useShareCopyIcon';
import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal';
import { usePostImpressions } from '../../../hooks/post/usePostImpressions';

Expand Down Expand Up @@ -73,6 +75,7 @@ const ActionButtons = ({
}: ActionButtonsProps): ReactElement | null => {
const config = variantConfig[variant];
const isFeedPreview = useFeedPreviewMode();
const showCopyIcon = useShareCopyIcon();
// When impressions are enabled, awards are hidden below laptop (tablet +
// mobile) to make room for the extra action.
const isLaptop = useViewSize(ViewSize.Laptop);
Expand Down Expand Up @@ -227,7 +230,7 @@ const ActionButtons = ({
<CardAction
id="copy-post-btn"
density={FEED_CARD_DENSITY}
icon={<LinkIcon />}
icon={showCopyIcon ? <CopyIcon /> : <LinkIcon />}
label="Copy link"
onClick={onCopyLink}
color={ButtonColor.Cabbage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import type { PostHeaderActionsProps } from '../common';
import Link from '../../utilities/Link';
import { Button, ButtonSize } from '../../buttons/Button';
import { settingsUrl } from '../../../lib/constants';
import { LinkIcon, SettingsIcon } from '../../icons';
import { CopyIcon, LinkIcon, SettingsIcon } from '../../icons';
import { useSharePost } from '../../../hooks/useSharePost';
import { useShareCopyIcon } from '../../../hooks/useShareCopyIcon';
import type { Origin } from '../../../lib/log';

const Container = classed('div', 'flex flex-row items-center');
Expand All @@ -27,15 +28,17 @@ export const BriefPostHeaderActions = ({
showShareButton?: boolean;
}): ReactElement => {
const { copyLink } = useSharePost(origin);
const showCopyIcon = useShareCopyIcon();

return (
<Container {...props} className={classNames('gap-2', className)}>
<div className="hidden laptop:block">
{showShareButton && (
<Button
icon={<LinkIcon />}
icon={showCopyIcon ? <CopyIcon /> : <LinkIcon />}
size={ButtonSize.Medium}
onClick={() => copyLink({ post })}
aria-label="Copy link"
/>
)}
<Link passHref href={`${settingsUrl}/notifications`}>
Expand Down
81 changes: 81 additions & 0 deletions packages/shared/src/components/share/ShareActions.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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 { ShareActions } from './ShareActions';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { ShareProvider } from '../../lib/share';
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 onShare = jest.fn();
const link = 'https://daily.dev/posts/abc';
const text = 'Check this out';

beforeEach(() => {
jest.clearAllMocks();
useViewSizeMock.mockReturnValue(true); // default: laptop
Object.assign(navigator, {
clipboard: { writeText },
});
});

const renderComponent = (
props: Partial<Parameters<typeof ShareActions>[0]> = {},
): RenderResult => {
const client = new QueryClient();
return render(
<TestBootProvider client={client}>
<ShareActions link={link} text={text} onShare={onShare} {...props} />
</TestBootProvider>,
);
};

describe('ShareActions inline variant', () => {
it('renders copy link plus a compact set of social networks', () => {
renderComponent({ variant: 'inline' });

expect(screen.getByText('Copy link')).toBeInTheDocument();
expect(screen.getByText('X')).toBeInTheDocument();
expect(screen.getByText('WhatsApp')).toBeInTheDocument();
});

it('copies the link and reports the CopyLink provider', async () => {
renderComponent({ variant: 'inline' });

await act(async () => {
fireEvent.click(screen.getByText('Copy link'));
});

await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink);
});
});

describe('ShareActions icon variant on mobile', () => {
beforeEach(() => useViewSizeMock.mockReturnValue(false));

it('copies on a single tap when native share is unavailable', async () => {
renderComponent();

const trigger = screen.getByLabelText('Copy link');
await act(async () => {
fireEvent.click(trigger);
});

await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink);
});
});
161 changes: 161 additions & 0 deletions packages/shared/src/components/share/ShareActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { ReactElement } from 'react';
import React, { useRef, useState } from 'react';
import classNames from 'classnames';
import { Popover, PopoverTrigger } from '@radix-ui/react-popover';
import { PopoverContent } from '../popover/Popover';
import { SocialShareList } from '../widgets/SocialShareList';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { CopyIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
import { Typography, TypographyType } from '../typography/Typography';
import { useViewSize, ViewSize } from '../../hooks/useViewSize';
import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink';
import { shouldUseNativeShare } from '../../lib/func';
import { ShareProvider } from '../../lib/share';
import type { ReferralCampaignKey } from '../../lib/referral';

export type ShareActionsVariant = 'icon' | 'inline';

export interface ShareActionsProps {
link: string;
/** Share text / description used for native share + pre-filled network text. */
text: string;
cid?: ReferralCampaignKey;
variant?: ShareActionsVariant;
/** Desktop only: reveal the popover on hover as well as click. */
openOnHover?: boolean;
buttonVariant?: ButtonVariant;
buttonSize?: ButtonSize;
/** Tooltip + accessible label for the icon-only trigger. */
label?: string;
emailTitle?: string;
emailSummary?: string;
className?: string;
/** Called for any share/copy so the caller can log with its own origin. */
onShare?: (provider: ShareProvider) => void;
}

const HOVER_CLOSE_DELAY = 120;

export function ShareActions({
link,
text,
cid,
variant = 'icon',
openOnHover = false,
buttonVariant = ButtonVariant.Tertiary,
buttonSize = ButtonSize.Small,
label = 'Copy link',
emailTitle,
emailSummary,
className,
onShare,
}: ShareActionsProps): ReactElement {
const isLaptop = useViewSize(ViewSize.Laptop);
const [open, setOpen] = useState(false);
const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid });
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();

const list = (
<SocialShareList
link={link}
description={text}
emailTitle={emailTitle}
emailSummary={emailSummary}
isCopying={copying}
onCopy={() => {
onShare?.(ShareProvider.CopyLink);
shareOrCopy();
}}
onNativeShare={() => {
onShare?.(ShareProvider.Native);
shareOrCopy();
}}
onClickSocial={(provider) => onShare?.(provider)}
/>
);

if (variant === 'inline') {
return (
<div className={classNames('flex flex-wrap gap-2', className)}>
{list}
</div>
);
}

// Mobile: a single tap goes straight to the native share sheet (or copy when
// native share is unavailable) — no popover, per sharing UX guidance.
if (!isLaptop) {
return (
<Tooltip content={label}>
<Button
type="button"
variant={buttonVariant}
size={buttonSize}
icon={<CopyIcon secondary={copying} />}
aria-label={label}
className={className}
onClick={() => {
onShare?.(
shouldUseNativeShare()
? ShareProvider.Native
: ShareProvider.CopyLink,
);
shareOrCopy();
}}
/>
</Tooltip>
);
}

const cancelClose = () => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
};
const hoverProps = openOnHover
? {
onMouseEnter: () => {
cancelClose();
setOpen(true);
},
onMouseLeave: () => {
closeTimeout.current = setTimeout(
() => setOpen(false),
HOVER_CLOSE_DELAY,
);
},
}
: undefined;

return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip content={label} visible={!open}>
<PopoverTrigger asChild>
<Button
type="button"
variant={buttonVariant}
size={buttonSize}
icon={<CopyIcon secondary={copying} />}
aria-label={label}
pressed={open}
className={className}
{...hoverProps}
/>
</PopoverTrigger>
</Tooltip>
<PopoverContent
side="top"
align="center"
avoidCollisions
className="flex w-80 flex-wrap justify-center gap-2 rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4 shadow-2 data-[side=bottom]:mt-1 data-[side=top]:mb-1"
{...hoverProps}
>
<Typography type={TypographyType.Callout} bold className="w-full">
Share
</Typography>
{list}
</PopoverContent>
</Popover>
);
}
14 changes: 14 additions & 0 deletions packages/shared/src/hooks/useShareCopyIcon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { featureShareCopyIcon } from '../lib/featureManagement';
import { useConditionalFeature } from './useConditionalFeature';

// Resolves whether copy-link actions should render the new `CopyIcon` instead
// of the legacy `LinkIcon`. Gated so the core-icon swap ramps behind
// `share_copy_icon` (control = false = existing `LinkIcon`, no metric impact).
export const useShareCopyIcon = (shouldEvaluate = true): boolean => {
const { value } = useConditionalFeature({
feature: featureShareCopyIcon,
shouldEvaluate,
});

return value;
};
22 changes: 22 additions & 0 deletions packages/shared/src/hooks/useSharingVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { featureSharingVisibility } from '../lib/featureManagement';
import { useConditionalFeature } from './useConditionalFeature';

interface UseSharingVisibility {
isEnabled: boolean;
isLoading: boolean;
}

// Master gate for the sharing-visibility initiative. Wraps the
// `sharing_visibility` kill-switch so every surface can guard on it with one
// call. Pass `shouldEvaluate` to avoid evaluating GrowthBook until the surface
// would actually render (per repo convention).
export const useSharingVisibility = (
shouldEvaluate = true,
): UseSharingVisibility => {
const { value, isLoading } = useConditionalFeature({
feature: featureSharingVisibility,
shouldEvaluate,
});

return { isEnabled: shouldEvaluate && value, isLoading };
};
Loading
Loading