From 2c6d103abe1c6a26777f6642d18422b2370395bf Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Jul 2026 20:48:04 +0300 Subject: [PATCH 1/2] feat(share): sharing-visibility foundation (PR 1) Reusable ShareActions primitive (icon/inline variants, desktop popover + mobile native share) plus the initiative's flags and a flag-gated core copy-icon swap. - add ShareActions + Storybook story, useSharingVisibility gate - add feature_sharing_visibility (master kill-switch) and share_copy_icon - gate LinkIcon->CopyIcon swap behind share_copy_icon (default off == main) in ShareMobile, ActionButtons.v2, BriefPostHeaderActions - add share-surface Origin values Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 11 ++ .../shared/src/components/ShareMobile.tsx | 6 +- .../cards/common/ActionButtons.v2.tsx | 5 +- .../post/brief/BriefPostHeaderActions.tsx | 7 +- .../components/share/ShareActions.spec.tsx | 81 +++++++++ .../src/components/share/ShareActions.tsx | 161 ++++++++++++++++++ packages/shared/src/hooks/useShareCopyIcon.ts | 14 ++ .../shared/src/hooks/useSharingVisibility.ts | 22 +++ packages/shared/src/lib/featureManagement.ts | 15 ++ packages/shared/src/lib/log.ts | 7 + .../components/ShareActions.stories.tsx | 113 ++++++++++++ 11 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 .claude/launch.json create mode 100644 packages/shared/src/components/share/ShareActions.spec.tsx create mode 100644 packages/shared/src/components/share/ShareActions.tsx create mode 100644 packages/shared/src/hooks/useShareCopyIcon.ts create mode 100644 packages/shared/src/hooks/useSharingVisibility.ts create mode 100644 packages/storybook/stories/components/ShareActions.stories.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000000..8ae9d3b6d54 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "storybook", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "storybook", "dev"], + "port": 6006 + } + ] +} diff --git a/packages/shared/src/components/ShareMobile.tsx b/packages/shared/src/components/ShareMobile.tsx index c5eef681837..e84795f2eeb 100644 --- a/packages/shared/src/components/ShareMobile.tsx +++ b/packages/shared/src/components/ShareMobile.tsx @@ -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, @@ -35,6 +36,7 @@ export function ShareMobile({ const { openSharePost } = useSharePost(origin); const { logEvent } = useLogContext(); const { logOpts } = useContext(ActiveFeedContext); + const showCopyIcon = useShareCopyIcon(); const onShare = () => { logEvent( @@ -51,7 +53,7 @@ export function ShareMobile({ size={ButtonSize.Small} onClick={onCopyPostLink} pressed={copying} - icon={} + icon={showCopyIcon ? : } variant={ButtonVariant.Tertiary} color={ButtonColor.Avocado} > diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 3bdf0f9270c..ee2f27641a6 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -7,6 +7,7 @@ import { CardActionBar } from '../../buttons/CardActionBar'; import { AnalyticsIcon, DiscussIcon as CommentIcon, + CopyIcon, LinkIcon, DownvoteIcon, } from '../../icons'; @@ -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'; @@ -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); @@ -227,7 +230,7 @@ const ActionButtons = ({ } + icon={showCopyIcon ? : } label="Copy link" onClick={onCopyLink} color={ButtonColor.Cabbage} diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index d1cb33406be..d31a26c0abb 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -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'); @@ -27,15 +28,17 @@ export const BriefPostHeaderActions = ({ showShareButton?: boolean; }): ReactElement => { const { copyLink } = useSharePost(origin); + const showCopyIcon = useShareCopyIcon(); return (
{showShareButton && (
{name} diff --git a/packages/shared/src/components/profile/ProfileShareButton.spec.tsx b/packages/shared/src/components/profile/ProfileShareButton.spec.tsx new file mode 100644 index 00000000000..1d4af1fbb03 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileShareButton.spec.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ProfileShareButton } from './ProfileShareButton'; +import { getLogContextStatic } from '../../contexts/LogContext'; +import AuthContext from '../../contexts/AuthContext'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import type { PublicProfile } from '../../lib/user'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import type { ToastNotification } from '../../hooks/useToastNotification'; +import { shouldUseNativeShare } from '../../lib/func'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +jest.mock('../../lib/func', () => ({ + ...jest.requireActual('../../lib/func'), + shouldUseNativeShare: jest.fn(), +})); + +const mockShouldUseNativeShare = jest.mocked(shouldUseNativeShare); + +const user = { + id: 'u1', + name: 'Ido Shamun', + username: 'idoshamun', + permalink: 'https://app.daily.dev/idoshamun', +} as PublicProfile; + +const logEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +const setupButton = ( + props: Partial> = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const LogContext = getLogContextStatic(); + + render( + + + false, + }} + > + + + + , + ); + + return client; +}; + +describe('ProfileShareButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShouldUseNativeShare.mockReturnValue(false); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + }); + + it('should label the control for the profile being copied', () => { + setupButton(); + + expect( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ).toBeInTheDocument(); + }); + + it('should label the control for the logged-in owner', () => { + setupButton({ isSameUser: true }); + + expect( + screen.getByLabelText('Copy link to your profile'), + ).toBeInTheDocument(); + }); + + it('should copy the profile link and name it in the toast', async () => { + const client = setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith('https://app.daily.dev/idoshamun'), + ); + await waitFor(() => { + const toast = client.getQueryData(TOAST_NOTIF_KEY); + expect(toast?.message).toEqual("✅ Copied link to @idoshamun's profile"); + }); + // Exact payload: `share profile` already ships from the profile menus and + // the owner share widget, and the post copy-link path uses the same + // target_id / target_type / stringified {provider, origin} shape. Pin it so + // the dashboards keep matching. + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareProfile, + target_id: 'u1', + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.Profile, + }), + }); + }); + + it('should say "your profile" in the owner toast', async () => { + const client = setupButton({ isSameUser: true }); + + await userEvent.click(screen.getByLabelText('Copy link to your profile')); + + await waitFor(() => { + const toast = client.getQueryData(TOAST_NOTIF_KEY); + expect(toast?.message).toEqual('✅ Copied link to your profile'); + }); + }); + + it('should flip the glyph to a green check while copying', async () => { + setupButton(); + + const button = screen.getByLabelText("Copy link to @idoshamun's profile"); + expect(button.querySelector('.text-status-success')).toBeNull(); + + await userEvent.click(button); + + await waitFor(() => + expect(button.querySelector('.text-status-success')).toBeInTheDocument(), + ); + }); + + it('should open the native share sheet on mobile when available', async () => { + mockShouldUseNativeShare.mockReturnValue(true); + const share = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(globalThis.navigator, 'share', { + configurable: true, + value: share, + }); + + setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ + text: "Check out Ido Shamun's profile on daily.dev\nhttps://app.daily.dev/idoshamun", + }), + ); + expect(writeText).not.toHaveBeenCalled(); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareProfile, + target_id: 'u1', + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ + provider: ShareProvider.Native, + origin: Origin.Profile, + }), + }); + }); + + it('should not log when the native share sheet is dismissed', async () => { + mockShouldUseNativeShare.mockReturnValue(true); + Object.defineProperty(globalThis.navigator, 'share', { + configurable: true, + value: jest.fn().mockRejectedValue(new Error('AbortError')), + }); + + setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => expect(logEvent).not.toHaveBeenCalled()); + }); + + it('should not open a share popover on desktop', async () => { + setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(screen.queryByText('LinkedIn')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/profile/ProfileShareButton.tsx b/packages/shared/src/components/profile/ProfileShareButton.tsx new file mode 100644 index 00000000000..5f7405a2544 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileShareButton.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { LinkIcon, VIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import type { PublicProfile } from '../../lib/user'; +import type { ShareProvider } from '../../lib/share'; + +export interface ProfileShareButtonProps { + user: PublicProfile; + isSameUser?: boolean; + buttonSize?: ButtonSize; + buttonVariant?: ButtonVariant; + className?: string; +} + +/** + * Copy-link control for a profile. One click copies the (shortened, referral- + * tagged) profile URL and confirms twice over — the glyph flips to a green + * check for a beat and a toast names what was copied. No share menu: picking a + * network is a second decision, and the link on the clipboard covers every + * destination. Mobile still gets the native share sheet where the platform + * offers one. + */ +export function ProfileShareButton({ + user, + isSameUser, + buttonSize = ButtonSize.Small, + buttonVariant = ButtonVariant.Subtle, + className, +}: ProfileShareButtonProps): ReactElement { + const text = isSameUser + ? 'Check out my profile on daily.dev' + : `Check out ${user.name}'s profile on daily.dev`; + const label = isSameUser + ? 'Copy link to your profile' + : `Copy link to @${user.username}'s profile`; + + const [copying, shareOrCopy] = useShareOrCopyLink({ + link: user.permalink, + text, + cid: ReferralCampaignKey.ShareProfile, + copyMessage: isSameUser + ? '✅ Copied link to your profile' + : `✅ Copied link to @${user.username}'s profile`, + logObject: (provider: ShareProvider) => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ provider, origin: Origin.Profile }), + }), + }); + + return ( + +
+); + +/** + * Renders another story in a narrow iframe so the genuinely viewport-driven + * branches (`useViewSize(ViewSize.Laptop)`) can be reviewed at mobile width on + * the same page — no faking, it is the real component at 390px. + */ +export const DeviceFrame = ({ + storyId, + width = 390, + height = 220, +}: { + storyId: string; + width?: number; + height?: number; +}): ReactElement => { + // The embedded story is a separate document, so Storybook's theme toggle + // doesn't reach it — mirror the root theme class into its globals instead. + const [theme, setTheme] = React.useState('light'); + + React.useEffect(() => { + const root = document.documentElement; + const sync = () => + setTheme(root.classList.contains('dark') ? 'dark' : 'light'); + sync(); + const observer = new MutationObserver(sync); + observer.observe(root, { attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); + }, []); + + return ( +
+