From 2ee8a9e6c4b9f5e402231321149767ce146e4b5c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 17:45:42 +0300 Subject: [PATCH 01/12] feat(share): surface a copy-link control on profile headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated share control to the profile header instead of burying it in the "..." options menu. - Own profile: sits next to "Edit profile". - Public profile: fills the slot the (inapplicable) edit button used to reserve, so the layout height is unchanged. - Stays well away from Follow (different card region, Float icon vs. the filled primary CTA) so the two intents don't read as one control group. - The pinned mobile profile header shows it only while pinned, where the profile card has scrolled away — otherwise there would be two identical copy buttons on screen. Built on the shared `ShareActions` primitive from PR 1, so mobile taps straight through to the native share sheet and desktop opens the network popover. Gated by `share_profile` plus the `sharing_visibility` master flag. Co-Authored-By: Claude Opus 4.8 --- .../components/CustomFeedOptionsMenu.spec.tsx | 63 ++++++ .../src/components/CustomFeedOptionsMenu.tsx | 22 +- .../shared/src/components/profile/Header.tsx | 38 +++- .../src/components/profile/ProfileActions.tsx | 26 ++- .../components/profile/ProfileHeader.spec.tsx | 127 +++++++++++ .../src/components/profile/ProfileHeader.tsx | 38 ++-- .../profile/ProfileShareButton.spec.tsx | 158 +++++++++++++ .../components/profile/ProfileShareButton.tsx | 60 +++++ .../hooks/profile/useShareProfileEnabled.ts | 17 ++ packages/shared/src/lib/featureManagement.ts | 7 + .../components/ProfileShareButton.stories.tsx | 207 ++++++++++++++++++ 11 files changed, 725 insertions(+), 38 deletions(-) create mode 100644 packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx create mode 100644 packages/shared/src/components/profile/ProfileHeader.spec.tsx create mode 100644 packages/shared/src/components/profile/ProfileShareButton.spec.tsx create mode 100644 packages/shared/src/components/profile/ProfileShareButton.tsx create mode 100644 packages/shared/src/hooks/profile/useShareProfileEnabled.ts create mode 100644 packages/storybook/stories/components/ProfileShareButton.stories.tsx diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx new file mode 100644 index 00000000000..a1bf38cc517 --- /dev/null +++ b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import CustomFeedOptionsMenu from './CustomFeedOptionsMenu'; +import AuthContext from '../contexts/AuthContext'; +import type { AuthContextData } from '../contexts/AuthContext'; + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useFeeds: () => ({ feeds: { edges: [] } }), +})); + +const shareProps = { + text: "Check out Ido Shamun's profile on daily.dev", + link: 'https://app.daily.dev/idoshamun', +}; + +const renderMenu = (props = {}) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + + return render( + + + + + , + ); +}; + +describe('CustomFeedOptionsMenu', () => { + it('should list the share option when share props are provided', async () => { + renderMenu({ shareProps }); + + // Radix opens the menu on keydown; jsdom lacks the pointer-event support + // its click path relies on. + fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' }); + + expect(await screen.findByText('Share')).toBeInTheDocument(); + expect(screen.getByText('Add to custom feed')).toBeInTheDocument(); + }); + + it('should drop the share option when the surface promotes it elsewhere', async () => { + renderMenu(); + + // Radix opens the menu on keydown; jsdom lacks the pointer-event support + // its click path relies on. + fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' }); + + expect(await screen.findByText('Add to custom feed')).toBeInTheDocument(); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.tsx index f4bc4b9bd64..15eb301e292 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.tsx @@ -26,7 +26,9 @@ type CustomFeedOptionsMenuProps = { button?: string; }; buttonVariant?: ButtonVariant; - shareProps: UseShareOrCopyLinkProps; + /** Omit to drop the "Share" entry, e.g. when the surface promotes it to its + * own visible control. */ + shareProps?: UseShareOrCopyLinkProps; additionalOptions?: MenuItemProps[]; }; @@ -40,7 +42,9 @@ const CustomFeedOptionsMenu = ({ buttonVariant = ButtonVariant.Float, }: CustomFeedOptionsMenuProps): ReactElement => { const { openModal } = useLazyModal(); - const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); + const [, onShareOrCopyLink] = useShareOrCopyLink( + shareProps ?? { link: '', text: '' }, + ); const { feeds } = useFeeds(); const handleOpenModal = () => { @@ -59,11 +63,15 @@ const CustomFeedOptionsMenu = ({ }; const options: MenuItemProps[] = [ - { - icon: , - label: 'Share', - action: () => onShareOrCopyLink(), - }, + ...(shareProps + ? [ + { + icon: , + label: 'Share', + action: () => onShareOrCopyLink(), + }, + ] + : []), { icon: , label: 'Add to custom feed', diff --git a/packages/shared/src/components/profile/Header.tsx b/packages/shared/src/components/profile/Header.tsx index bfc29ef27a9..868ee3aa046 100644 --- a/packages/shared/src/components/profile/Header.tsx +++ b/packages/shared/src/components/profile/Header.tsx @@ -45,6 +45,8 @@ import { import Link from '../utilities/Link'; import type { MenuItemProps } from '../dropdown/common'; import { ProfileMobileBackButton } from './ProfileBackButton'; +import { ProfileShareButton } from './ProfileShareButton'; +import { useShareProfileEnabled } from '../../hooks/profile/useShareProfileEnabled'; export interface HeaderProps { user: PublicProfile; @@ -83,6 +85,7 @@ export function Header({ }); const hasCoresAccess = useHasAccessToCores(); const canPurchaseCores = useCanPurchaseCores(); + const isShareEnabled = useShareProfileEnabled(); const onReportUser = React.useCallback( (defaultBlocked = false) => { @@ -218,6 +221,17 @@ export function Header({ variant={ButtonVariant.Float} /> )} + {/* Only while pinned: unpinned, the profile card right below owns the + share control, and two identical copy buttons on one screen read as + a mistake. `ml-1` keeps this utility icon out of the Follow group. */} + {isShareEnabled && sticky && ( + + )} {!isSameUser && ( @@ -241,15 +255,21 @@ export function Header({ `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, ) } - shareProps={{ - text: `Check out ${user.name}'s profile on daily.dev`, - link: user.permalink, - cid: ReferralCampaignKey.ShareProfile, - logObject: () => ({ - event_name: LogEvent.ShareProfile, - target_id: user.id, - }), - }} + // Promoted out of the menu into a dedicated control when the + // share-profile experiment is on. + shareProps={ + isShareEnabled + ? undefined + : { + text: `Check out ${user.name}'s profile on daily.dev`, + link: user.permalink, + cid: ReferralCampaignKey.ShareProfile, + logObject: () => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + }), + } + } additionalOptions={options} /> )} diff --git a/packages/shared/src/components/profile/ProfileActions.tsx b/packages/shared/src/components/profile/ProfileActions.tsx index d81d537948d..42b2897f883 100644 --- a/packages/shared/src/components/profile/ProfileActions.tsx +++ b/packages/shared/src/components/profile/ProfileActions.tsx @@ -37,6 +37,7 @@ import { AwardButton } from '../award/AwardButton'; import { Tooltip } from '../tooltip/Tooltip'; import { useAuthContext } from '../../contexts/AuthContext'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; +import { useShareProfileEnabled } from '../../hooks/profile/useShareProfileEnabled'; import type { MenuItemProps } from '../dropdown/common'; export interface HeaderProps { @@ -59,6 +60,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { sendingUser: loggedUser, receivingUser: user as LoggedUser, }); + const isShareEnabled = useShareProfileEnabled(); const onReportUser = React.useCallback( (defaultBlocked = false) => { @@ -195,15 +197,21 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, ) } - shareProps={{ - text: `Check out ${user.name}'s profile on daily.dev`, - link: user.permalink, - cid: ReferralCampaignKey.ShareProfile, - logObject: () => ({ - event_name: LogEvent.ShareProfile, - target_id: user.id, - }), - }} + // Promoted out of the menu into the header's share control when the + // share-profile experiment is on. + shareProps={ + isShareEnabled + ? undefined + : { + text: `Check out ${user.name}'s profile on daily.dev`, + link: user.permalink, + cid: ReferralCampaignKey.ShareProfile, + logObject: () => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + }), + } + } additionalOptions={options} /> diff --git a/packages/shared/src/components/profile/ProfileHeader.spec.tsx b/packages/shared/src/components/profile/ProfileHeader.spec.tsx new file mode 100644 index 00000000000..13e44192892 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileHeader.spec.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import ProfileHeader from './ProfileHeader'; +import AuthContext from '../../contexts/AuthContext'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import { getLogContextStatic } from '../../contexts/LogContext'; +import type { PublicProfile } from '../../lib/user'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; + +jest.mock('../../hooks/useConditionalFeature'); +jest.mock('./ProfileActions', () => ({ + __esModule: true, + default: () =>
, +})); + +const mockUseConditionalFeature = jest.mocked(useConditionalFeature); + +const user = { + id: 'u1', + name: 'Ido Shamun', + username: 'idoshamun', + permalink: 'https://app.daily.dev/idoshamun', + reputation: 10, + createdAt: '2020-01-01T00:00:00.000Z', + bio: 'Building daily.dev', + image: 'https://daily.dev/image.jpg', + cover: 'https://daily.dev/cover.jpg', +} as PublicProfile; + +const userStats = { upvotes: 1, numFollowers: 2, numFollowing: 3 }; + +const renderHeader = (isSameUser: boolean) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const LogContext = getLogContextStatic(); + + return render( + + + false, + }} + > + + + + , + ); +}; + +describe('ProfileHeader share control', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('when the flag is off', () => { + beforeEach(() => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + }); + + it('should not render a share control on a public profile', () => { + renderHeader(false); + + expect(screen.queryByLabelText(/Share/)).not.toBeInTheDocument(); + }); + + it('should keep the invisible edit placeholder on a public profile', () => { + renderHeader(false); + + expect(screen.getByLabelText('Edit profile')).toHaveClass('invisible'); + }); + + it('should keep the edit button on the owner profile', () => { + renderHeader(true); + + expect(screen.getByLabelText('Edit profile')).not.toHaveClass( + 'invisible', + ); + expect(screen.queryByLabelText(/Share/)).not.toBeInTheDocument(); + }); + }); + + describe('when the flag is on', () => { + beforeEach(() => { + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + }); + + it('should fill the edit slot with the share control on a public profile', () => { + renderHeader(false); + + expect( + screen.getByLabelText("Share @idoshamun's profile"), + ).toBeInTheDocument(); + expect(screen.queryByLabelText('Edit profile')).not.toBeInTheDocument(); + }); + + it('should sit next to the edit button on the owner profile', () => { + renderHeader(true); + + expect(screen.getByLabelText('Share your profile')).toBeInTheDocument(); + expect(screen.getByLabelText('Edit profile')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 24a3ae432e9..41bf8cf1688 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -23,6 +23,8 @@ import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; +import { ProfileShareButton } from './ProfileShareButton'; +import { useShareProfileEnabled } from '../../hooks/profile/useShareProfileEnabled'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -61,6 +63,7 @@ const ProfileHeader = ({ const { name, username, bio, image, cover, isPlus } = user; const { user: loggedUser } = useAuthContext(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; + const isShareEnabled = useShareProfileEnabled(); return (
@@ -75,19 +78,28 @@ const ProfileHeader = ({ className="absolute left-6 top-16 h-[7.5rem] w-[7.5rem] rounded-16 object-cover" />
- -
{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..868df573aa0 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileShareButton.spec.tsx @@ -0,0 +1,158 @@ +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 { useViewSize } from '../../hooks/useViewSize'; +import { LogEvent } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +jest.mock('../../lib/func', () => ({ + ...jest.requireActual('../../lib/func'), + shouldUseNativeShare: jest.fn(), +})); + +jest.mock('../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +const mockShouldUseNativeShare = jest.mocked(shouldUseNativeShare); +const mockUseViewSize = jest.mocked(useViewSize); + +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(); + mockUseViewSize.mockReturnValue(false); + mockShouldUseNativeShare.mockReturnValue(false); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + }); + + it('should label the control for the profile being shared', () => { + setupButton(); + + expect( + screen.getByLabelText("Share @idoshamun's profile"), + ).toBeInTheDocument(); + }); + + it('should label the control for the logged-in owner', () => { + setupButton({ isSameUser: true }); + + expect(screen.getByLabelText('Share your profile')).toBeInTheDocument(); + }); + + it('should copy the profile link and toast on mobile without native share', async () => { + const client = setupButton(); + + await userEvent.click(screen.getByLabelText("Share @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 clipboard'); + }); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.ShareProfile, + target_id: 'u1', + extra: expect.stringContaining(ShareProvider.CopyLink), + }), + ); + }); + + 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("Share @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).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.ShareProfile, + extra: expect.stringContaining(ShareProvider.Native), + }), + ); + }); + + it('should reveal the share network list on desktop', async () => { + mockUseViewSize.mockReturnValue(true); + setupButton(); + + await userEvent.click(screen.getByLabelText("Share @idoshamun's profile")); + + expect(await screen.findByText('Copy link')).toBeInTheDocument(); + expect(screen.getByText('LinkedIn')).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..eee73ffadc4 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileShareButton.tsx @@ -0,0 +1,60 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ShareActions } from '../share/ShareActions'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +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/share control for a profile. Wraps the shared `ShareActions` primitive + * so every profile surface ships the same share text, referral campaign and + * analytics payload. + */ +export function ProfileShareButton({ + user, + isSameUser, + buttonSize = ButtonSize.Medium, + buttonVariant = ButtonVariant.Float, + className, +}: ProfileShareButtonProps): ReactElement { + const { logEvent } = useLogContext(); + const text = isSameUser + ? 'Check out my profile on daily.dev' + : `Check out ${user.name}'s profile on daily.dev`; + const label = isSameUser + ? 'Share your profile' + : `Share @${user.username}'s profile`; + + const onShare = (provider: ShareProvider) => + logEvent({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ provider, origin: Origin.Profile }), + }); + + return ( + + ); +} diff --git a/packages/shared/src/hooks/profile/useShareProfileEnabled.ts b/packages/shared/src/hooks/profile/useShareProfileEnabled.ts new file mode 100644 index 00000000000..bd3454e0993 --- /dev/null +++ b/packages/shared/src/hooks/profile/useShareProfileEnabled.ts @@ -0,0 +1,17 @@ +import { featureShareProfile } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { useSharingVisibility } from '../useSharingVisibility'; + +// Gate for the profile share affordance. It has to pass both the per-topic +// `share_profile` flag and the initiative-wide `sharing_visibility` +// kill-switch, and every profile surface needs the same answer, so the pair is +// resolved in one place. +export const useShareProfileEnabled = (shouldEvaluate = true): boolean => { + const { isEnabled: isSharingVisible } = useSharingVisibility(shouldEvaluate); + const { value } = useConditionalFeature({ + feature: featureShareProfile, + shouldEvaluate, + }); + + return isSharingVisible && value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..d1f6c71eb2a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,10 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Surfaces a copy/share control on the profile header (own profile next to +// "Edit profile", public profiles in the otherwise-empty edit slot) and +// promotes the share action out of the "..." options menu. Also gated by the +// `sharing_visibility` master flag. Keep the default `false` — GrowthBook ramps +// it. +export const featureShareProfile = new Feature('share_profile', false); diff --git a/packages/storybook/stories/components/ProfileShareButton.stories.tsx b/packages/storybook/stories/components/ProfileShareButton.stories.tsx new file mode 100644 index 00000000000..756551dbd1b --- /dev/null +++ b/packages/storybook/stories/components/ProfileShareButton.stories.tsx @@ -0,0 +1,207 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import ProfileHeader from '@dailydotdev/shared/src/components/profile/ProfileHeader'; +import { ProfileShareButton } from '@dailydotdev/shared/src/components/profile/ProfileShareButton'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + FeaturesReadyContext, + GrowthBookProvider, +} from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import type { + LoggedUser, + PublicProfile as PublicProfileType, +} from '@dailydotdev/shared/src/lib/user'; + +const mockUser = { + id: 'u1', + name: 'Ido Shamun', + username: 'idoshamun', + email: 'ido@daily.dev', + image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + createdAt: '2020-01-01T00:00:00.000Z', + permalink: 'https://app.daily.dev/idoshamun', +} as unknown as LoggedUser; + +const profile = { + ...mockUser, + bio: 'Co-founder of daily.dev. Building the homepage millions of developers open every morning.', + cover: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/placeholders/cover', + reputation: 4210, + isPlus: true, +} as unknown as PublicProfileType; + +const userStats = { upvotes: 1240, numFollowers: 8300, numFollowing: 210 }; + +const SHORT_LINK = 'https://dly.to/abc123'; + +// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` +// coerces every falsy default to the truthy string `'control'`, so a flag can't +// be evaluated as `false` here. Flag-off is therefore simulated by holding the +// features context as "not ready", which is the exact path +// `useConditionalFeature` takes to fall back to the (false) default value. +const withProviders = + (enabled: boolean) => + (Story: React.ComponentType): React.ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + const { queryKey } = getShortLinkProps( + profile.permalink, + ReferralCampaignKey.ShareProfile, + mockUser, + ); + queryClient.setQueryData(queryKey, SHORT_LINK); + + const LogContext = getLogContextStatic(); + + return ( + + + + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + feature.defaultValue as any, + }} + > + false, + }} + > +
+ +
+
+
+
+
+
+ ); + }; + +const meta: Meta = { + title: 'Components/Share/ProfileShareButton', + component: ProfileShareButton, + args: { user: profile }, + parameters: { + docs: { + description: { + component: + 'Profile copy/share control built on the shared `ShareActions` primitive. Gated by the `share_profile` flag plus the `sharing_visibility` master flag. On the owner profile it sits next to "Edit profile"; on public profiles it fills the slot the edit button leaves empty, well away from Follow so the two intents never read as one control group.', + }, + }, + }, + decorators: [withProviders(true)], +}; + +export default meta; + +type Story = StoryObj; + +// Public profile: the label names whose profile is being shared. +export const OnPublicProfile: Story = {}; + +// Owner profile: first-person label, same control. +export const OwnProfile: Story = { + args: { isSameUser: true }, +}; + +// Desktop: clicking the trigger reveals the full share network list. +export const NetworkList: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await userEvent.click( + within(canvasElement).getByLabelText("Share @idoshamun's profile"), + ); + await waitFor(() => expect(canvas.getByText('LinkedIn')).toBeVisible()); + }, +}; + +// Copying state: the copy chip flips to "Copied!" for a beat. The clipboard is +// stubbed because the Storybook iframe isn't allowed to write to the real one. +export const Copying: Story = { + play: async ({ canvasElement }) => { + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => undefined }, + }); + + const canvas = within(canvasElement.ownerDocument.body); + await userEvent.click( + within(canvasElement).getByLabelText("Share @idoshamun's profile"), + ); + await userEvent.click(await canvas.findByTestId('social-share-Copy link')); + await waitFor(() => expect(canvas.getByText('Copied!')).toBeInTheDocument()); + }, +}; + +type HeaderStory = StoryObj; + +const headerMeta = { + render: (args: React.ComponentProps) => ( + + ), +}; + +// The control in place on a public profile header — it takes over the slot the +// (inapplicable) edit button used to reserve. +export const InPublicHeader: HeaderStory = { + ...headerMeta, + args: { user: profile, userStats, isSameUser: false }, + decorators: [withProviders(true)], +}; + +// The control in place on the owner's header, alongside "Edit profile". +export const InOwnHeader: HeaderStory = { + ...headerMeta, + args: { user: profile, userStats, isSameUser: true }, + decorators: [withProviders(true)], +}; + +// Flag off — the header must render exactly what ships today: an invisible +// edit-button placeholder on public profiles, no share control anywhere. +export const HeaderControl: HeaderStory = { + ...headerMeta, + args: { user: profile, userStats, isSameUser: false }, + decorators: [withProviders(false)], +}; From 0df4e1f234ff9e5993118bf9ad6a214e01343162 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 18:08:59 +0300 Subject: [PATCH 02/12] refactor(share): unify menu share suppression on hideShare Reconciles the CustomFeedOptionsMenu API with the tags/sources branch: shareProps stays required (no empty-link fallback into useShareOrCopyLink) and surfaces that promote share to a visible control pass hideShare instead. File is now byte-identical on both branches so the stack merges cleanly. Co-Authored-By: Claude Opus 4.8 --- .../components/CustomFeedOptionsMenu.spec.tsx | 4 ++-- .../src/components/CustomFeedOptionsMenu.tsx | 19 ++++++++------- .../shared/src/components/profile/Header.tsx | 23 ++++++++----------- .../src/components/profile/ProfileActions.tsx | 23 ++++++++----------- 4 files changed, 31 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx index a1bf38cc517..028ec014cf1 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx @@ -39,7 +39,7 @@ const renderMenu = (props = {}) => { }; describe('CustomFeedOptionsMenu', () => { - it('should list the share option when share props are provided', async () => { + it('should list the share option by default', async () => { renderMenu({ shareProps }); // Radix opens the menu on keydown; jsdom lacks the pointer-event support @@ -51,7 +51,7 @@ describe('CustomFeedOptionsMenu', () => { }); it('should drop the share option when the surface promotes it elsewhere', async () => { - renderMenu(); + renderMenu({ shareProps, hideShare: true }); // Radix opens the menu on keydown; jsdom lacks the pointer-event support // its click path relies on. diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.tsx index 15eb301e292..71459bdc71d 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.tsx @@ -26,10 +26,10 @@ type CustomFeedOptionsMenuProps = { button?: string; }; buttonVariant?: ButtonVariant; - /** Omit to drop the "Share" entry, e.g. when the surface promotes it to its - * own visible control. */ - shareProps?: UseShareOrCopyLinkProps; + shareProps: UseShareOrCopyLinkProps; additionalOptions?: MenuItemProps[]; + /** Drop the in-menu share entry when the surface renders a visible one. */ + hideShare?: boolean; }; const CustomFeedOptionsMenu = ({ @@ -40,11 +40,10 @@ const CustomFeedOptionsMenu = ({ onCreateNewFeed, additionalOptions = [], buttonVariant = ButtonVariant.Float, + hideShare = false, }: CustomFeedOptionsMenuProps): ReactElement => { const { openModal } = useLazyModal(); - const [, onShareOrCopyLink] = useShareOrCopyLink( - shareProps ?? { link: '', text: '' }, - ); + const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); const { feeds } = useFeeds(); const handleOpenModal = () => { @@ -63,15 +62,15 @@ const CustomFeedOptionsMenu = ({ }; const options: MenuItemProps[] = [ - ...(shareProps - ? [ + ...(hideShare + ? [] + : [ { icon: , label: 'Share', action: () => onShareOrCopyLink(), }, - ] - : []), + ]), { icon: , label: 'Add to custom feed', diff --git a/packages/shared/src/components/profile/Header.tsx b/packages/shared/src/components/profile/Header.tsx index 868ee3aa046..49708e3d399 100644 --- a/packages/shared/src/components/profile/Header.tsx +++ b/packages/shared/src/components/profile/Header.tsx @@ -257,19 +257,16 @@ export function Header({ } // Promoted out of the menu into a dedicated control when the // share-profile experiment is on. - shareProps={ - isShareEnabled - ? undefined - : { - text: `Check out ${user.name}'s profile on daily.dev`, - link: user.permalink, - cid: ReferralCampaignKey.ShareProfile, - logObject: () => ({ - event_name: LogEvent.ShareProfile, - target_id: user.id, - }), - } - } + hideShare={isShareEnabled} + shareProps={{ + text: `Check out ${user.name}'s profile on daily.dev`, + link: user.permalink, + cid: ReferralCampaignKey.ShareProfile, + logObject: () => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + }), + }} additionalOptions={options} /> )} diff --git a/packages/shared/src/components/profile/ProfileActions.tsx b/packages/shared/src/components/profile/ProfileActions.tsx index 42b2897f883..04892c8fe0c 100644 --- a/packages/shared/src/components/profile/ProfileActions.tsx +++ b/packages/shared/src/components/profile/ProfileActions.tsx @@ -199,19 +199,16 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { } // Promoted out of the menu into the header's share control when the // share-profile experiment is on. - shareProps={ - isShareEnabled - ? undefined - : { - text: `Check out ${user.name}'s profile on daily.dev`, - link: user.permalink, - cid: ReferralCampaignKey.ShareProfile, - logObject: () => ({ - event_name: LogEvent.ShareProfile, - target_id: user.id, - }), - } - } + hideShare={isShareEnabled} + shareProps={{ + text: `Check out ${user.name}'s profile on daily.dev`, + link: user.permalink, + cid: ReferralCampaignKey.ShareProfile, + logObject: () => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + }), + }} additionalOptions={options} />
From e4f38f6a5c142e0fc0682955956aeb7e2f795f36 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 22:01:08 +0300 Subject: [PATCH 03/12] fix(share): short-circuit share_profile behind the master gate Evaluate the per-topic flag only once sharing_visibility passes, so control users are never bucketed into the experiment. Matches the other topic gates. Also types the menu spec helper now that shareProps is required again. Co-Authored-By: Claude Opus 4.8 --- .../components/CustomFeedOptionsMenu.spec.tsx | 11 +- .../profile/useShareProfileEnabled.spec.tsx | 105 ++++++++++++++++++ .../hooks/profile/useShareProfileEnabled.ts | 5 +- 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/hooks/profile/useShareProfileEnabled.spec.tsx diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx index 028ec014cf1..dc3c3e430c0 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react'; import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen } from '@testing-library/react'; @@ -15,7 +16,9 @@ const shareProps = { link: 'https://app.daily.dev/idoshamun', }; -const renderMenu = (props = {}) => { +const renderMenu = ( + props: Partial> = {}, +) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, }); @@ -32,7 +35,11 @@ const renderMenu = (props = {}) => { } as unknown as AuthContextData } > - + , ); diff --git a/packages/shared/src/hooks/profile/useShareProfileEnabled.spec.tsx b/packages/shared/src/hooks/profile/useShareProfileEnabled.spec.tsx new file mode 100644 index 00000000000..dea6c80e8c3 --- /dev/null +++ b/packages/shared/src/hooks/profile/useShareProfileEnabled.spec.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { useShareProfileEnabled } from './useShareProfileEnabled'; +import { useConditionalFeature } from '../useConditionalFeature'; +import type { Feature } from '../../lib/featureManagement'; +import { + featureSharingVisibility, + featureShareProfile, +} from '../../lib/featureManagement'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; + +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +const conditionalFeatureMock = useConditionalFeature as jest.Mock; +const evaluated: string[] = []; + +const mockFlags = (flags: Record) => + conditionalFeatureMock.mockImplementation( + ({ + feature, + shouldEvaluate, + }: { + feature: Feature; + shouldEvaluate?: boolean; + }) => { + if (shouldEvaluate !== false) { + evaluated.push(feature.id); + } + + return { + value: + shouldEvaluate === false ? feature.defaultValue : flags[feature.id], + isLoading: false, + }; + }, + ); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +beforeEach(() => { + jest.clearAllMocks(); + evaluated.length = 0; +}); + +describe('useShareProfileEnabled', () => { + it('is off while the sharing-visibility kill switch is off', () => { + mockFlags({ + [featureSharingVisibility.id]: false, + [featureShareProfile.id]: true, + }); + + const { result } = renderHook(() => useShareProfileEnabled(), { + wrapper, + }); + + expect(result.current).toBe(false); + // The per-topic flag must not be evaluated for control users. + expect(evaluated).not.toContain(featureShareProfile.id); + }); + + it('is off when the per-topic flag is off', () => { + mockFlags({ + [featureSharingVisibility.id]: true, + [featureShareProfile.id]: false, + }); + + const { result } = renderHook(() => useShareProfileEnabled(), { + wrapper, + }); + + expect(result.current).toBe(false); + }); + + it('is on only when both flags are on', () => { + mockFlags({ + [featureSharingVisibility.id]: true, + [featureShareProfile.id]: true, + }); + + const { result } = renderHook(() => useShareProfileEnabled(), { + wrapper, + }); + + expect(result.current).toBe(true); + }); + + it('never evaluates anything when the caller opts out', () => { + mockFlags({ + [featureSharingVisibility.id]: true, + [featureShareProfile.id]: true, + }); + + const { result } = renderHook(() => useShareProfileEnabled(false), { + wrapper, + }); + + expect(result.current).toBe(false); + expect(evaluated).toHaveLength(0); + }); +}); diff --git a/packages/shared/src/hooks/profile/useShareProfileEnabled.ts b/packages/shared/src/hooks/profile/useShareProfileEnabled.ts index bd3454e0993..73e464a2bec 100644 --- a/packages/shared/src/hooks/profile/useShareProfileEnabled.ts +++ b/packages/shared/src/hooks/profile/useShareProfileEnabled.ts @@ -5,12 +5,13 @@ import { useSharingVisibility } from '../useSharingVisibility'; // Gate for the profile share affordance. It has to pass both the per-topic // `share_profile` flag and the initiative-wide `sharing_visibility` // kill-switch, and every profile surface needs the same answer, so the pair is -// resolved in one place. +// resolved in one place. The per-topic flag is only evaluated once the master +// passes, so control users never get bucketed into the experiment. export const useShareProfileEnabled = (shouldEvaluate = true): boolean => { const { isEnabled: isSharingVisible } = useSharingVisibility(shouldEvaluate); const { value } = useConditionalFeature({ feature: featureShareProfile, - shouldEvaluate, + shouldEvaluate: shouldEvaluate && isSharingVisible, }); return isSharingVisible && value; From ece251689d20ae8ac08e1fa8dd8e83fe10d9f68b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 23:28:59 +0300 Subject: [PATCH 04/12] docs(storybook): add a sharing-visibility states & variants review page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts every surface the two share PRs touch on one scrollable page — flag-off next to flag-on — so the experiment can be reviewed without running the app or flipping GrowthBook. - stories/share/Overview.stories.tsx: 11 sections covering the flag matrix, the ShareActions primitive (icon/inline/hover, the four button variant+size pairs actually used), both profile headers, the pinned mobile bar, the menu before/after, the icon swap and the copy inventory. Mobile-only branches are embedded as real 390px story frames rather than mocked. - stories/share/shareStoryContext.tsx: the providers, mock profile and flag-on/flag-off scope, previously duplicated in both share story files, now defined once and reused by all three. - stories/share/reviewLayout.tsx: presentational scaffolding for the page, themed off CSS variables so it follows the light/dark toggle. Co-Authored-By: Claude Opus 5 --- .../components/ProfileShareButton.stories.tsx | 130 +--- .../components/ShareActions.stories.tsx | 68 +- .../stories/share/Overview.stories.tsx | 633 ++++++++++++++++++ .../storybook/stories/share/reviewLayout.tsx | 333 +++++++++ .../stories/share/shareStoryContext.tsx | 180 +++++ 5 files changed, 1161 insertions(+), 183 deletions(-) create mode 100644 packages/storybook/stories/share/Overview.stories.tsx create mode 100644 packages/storybook/stories/share/reviewLayout.tsx create mode 100644 packages/storybook/stories/share/shareStoryContext.tsx diff --git a/packages/storybook/stories/components/ProfileShareButton.stories.tsx b/packages/storybook/stories/components/ProfileShareButton.stories.tsx index 756551dbd1b..7d6d9580365 100644 --- a/packages/storybook/stories/components/ProfileShareButton.stories.tsx +++ b/packages/storybook/stories/components/ProfileShareButton.stories.tsx @@ -1,123 +1,13 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import ProfileHeader from '@dailydotdev/shared/src/components/profile/ProfileHeader'; import { ProfileShareButton } from '@dailydotdev/shared/src/components/profile/ProfileShareButton'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import { - FeaturesReadyContext, - GrowthBookProvider, -} from '@dailydotdev/shared/src/components/GrowthBookProvider'; -import { BootApp } from '@dailydotdev/shared/src/lib/boot'; -import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; -import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; -import type { - LoggedUser, - PublicProfile as PublicProfileType, -} from '@dailydotdev/shared/src/lib/user'; - -const mockUser = { - id: 'u1', - name: 'Ido Shamun', - username: 'idoshamun', - email: 'ido@daily.dev', - image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - providers: ['google'], - createdAt: '2020-01-01T00:00:00.000Z', - permalink: 'https://app.daily.dev/idoshamun', -} as unknown as LoggedUser; - -const profile = { - ...mockUser, - bio: 'Co-founder of daily.dev. Building the homepage millions of developers open every morning.', - cover: - 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/placeholders/cover', - reputation: 4210, - isPlus: true, -} as unknown as PublicProfileType; - -const userStats = { upvotes: 1240, numFollowers: 8300, numFollowing: 210 }; - -const SHORT_LINK = 'https://dly.to/abc123'; - -// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` -// coerces every falsy default to the truthy string `'control'`, so a flag can't -// be evaluated as `false` here. Flag-off is therefore simulated by holding the -// features context as "not ready", which is the exact path -// `useConditionalFeature` takes to fall back to the (false) default value. -const withProviders = - (enabled: boolean) => - (Story: React.ComponentType): React.ReactElement => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }); - - const { queryKey } = getShortLinkProps( - profile.permalink, - ReferralCampaignKey.ShareProfile, - mockUser, - ); - queryClient.setQueryData(queryKey, SHORT_LINK); - - const LogContext = getLogContextStatic(); - - return ( - - - - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - feature.defaultValue as any, - }} - > - false, - }} - > -
- -
-
-
-
-
-
- ); - }; + profile, + userStats, + withShareProviders, +} from '../share/shareStoryContext'; const meta: Meta = { title: 'Components/Share/ProfileShareButton', @@ -131,7 +21,7 @@ const meta: Meta = { }, }, }, - decorators: [withProviders(true)], + decorators: [withShareProviders(true)], }; export default meta; @@ -171,7 +61,9 @@ export const Copying: Story = { within(canvasElement).getByLabelText("Share @idoshamun's profile"), ); await userEvent.click(await canvas.findByTestId('social-share-Copy link')); - await waitFor(() => expect(canvas.getByText('Copied!')).toBeInTheDocument()); + await waitFor(() => + expect(canvas.getByText('Copied!')).toBeInTheDocument(), + ); }, }; @@ -188,14 +80,12 @@ const headerMeta = { export const InPublicHeader: HeaderStory = { ...headerMeta, args: { user: profile, userStats, isSameUser: false }, - decorators: [withProviders(true)], }; // The control in place on the owner's header, alongside "Edit profile". export const InOwnHeader: HeaderStory = { ...headerMeta, args: { user: profile, userStats, isSameUser: true }, - decorators: [withProviders(true)], }; // Flag off — the header must render exactly what ships today: an invisible @@ -203,5 +93,5 @@ export const InOwnHeader: HeaderStory = { export const HeaderControl: HeaderStory = { ...headerMeta, args: { user: profile, userStats, isSameUser: false }, - decorators: [withProviders(false)], + decorators: [withShareProviders(false)], }; diff --git a/packages/storybook/stories/components/ShareActions.stories.tsx b/packages/storybook/stories/components/ShareActions.stories.tsx index 0857b12c550..8c6592a0221 100644 --- a/packages/storybook/stories/components/ShareActions.stories.tsx +++ b/packages/storybook/stories/components/ShareActions.stories.tsx @@ -1,11 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; -import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; import { fn } from 'storybook/test'; +import { withShareProviders } from '../share/shareStoryContext'; const meta: Meta = { title: 'Components/Share/ShareActions', @@ -31,64 +27,10 @@ const meta: Meta = { text: { control: 'text' }, }, decorators: [ - (Story) => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }); - // Mock the short-URL resolution so copy/social actions don't hit network. - queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123'); - - const LogContext = getLogContextStatic(); - - 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; - - return ( - - - false, - }} - > -
- -
-
-
-
- ); - }, + withShareProviders( + true, + 'flex min-h-40 w-full items-center justify-center', + ), ], }; diff --git a/packages/storybook/stories/share/Overview.stories.tsx b/packages/storybook/stories/share/Overview.stories.tsx new file mode 100644 index 00000000000..33fd42c0717 --- /dev/null +++ b/packages/storybook/stories/share/Overview.stories.tsx @@ -0,0 +1,633 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { ProfileShareButton } from '@dailydotdev/shared/src/components/profile/ProfileShareButton'; +import ProfileHeader from '@dailydotdev/shared/src/components/profile/ProfileHeader'; +import { Header as ProfileMobileHeader } from '@dailydotdev/shared/src/components/profile/Header'; +import CustomFeedOptionsMenu from '@dailydotdev/shared/src/components/CustomFeedOptionsMenu'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CopyIcon, LinkIcon } from '@dailydotdev/shared/src/components/icons'; +import { + FlagScope, + ShareStoryProviders, + profile, + userStats, + withShareProviders, +} from './shareStoryContext'; +import { + Code, + DeviceFrame, + Grid, + Muted, + Page, + PageHeader, + Section, + Specimen, + Table, +} from './reviewLayout'; + +/** + * Review page for the sharing-visibility initiative: every surface the two + * share PRs touch, in both flag states, on one scrollable page. + * + * PR 1 — #6343 `ShareActions` primitive + `sharing_visibility` / `share_copy_icon` + * PR 2 — #6354 profile share control + `share_profile` + */ + +const shareProps = { + text: `Check out ${profile.name}'s profile on daily.dev`, + link: profile.permalink, + cid: undefined, +}; + +const menuProps = { + onAdd: fn(), + onUndo: fn(), + onCreateNewFeed: fn(), + shareProps, +}; + +/** Live specimen wrapper: one flag state, laid out inside a Specimen card. */ +const Live = ({ + enabled, + children, +}: { + enabled: boolean; + children: ReactNode; +}): ReactElement => {children}; + +const HeaderCard = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +const Review = (): ReactElement => ( + + + Two PRs make sharing visible instead of buried in a menu: + PR 1 adds the shared ShareActions primitive and the icon + swap, PR 2 puts a real copy-link control on profile headers. Everything + below is the live component — the left column of each pair is what a + control user sees today, the right column is the experiment. + + +
+ + Every surface below resolves its own flag, but they all sit under the{' '} + sharing_visibility master kill-switch, so the whole + initiative can be turned off in one toggle. + + share_profile, + ], + [ + 'Profile header — public profile', + 'An invisible Edit placeholder holding the row height', + 'Copy-link button fills that slot', + share_profile, + ], + [ + 'Pinned mobile profile bar', + 'Share lives inside the ⋯ menu', + 'Copy-link icon in the bar, only while pinned', + share_profile, + ], + [ + 'Profile ⋯ menus (bar + actions card)', + '“Share” menu entry', + 'Entry removed — promoted to the visible control', + share_profile, + ], + [ + 'Feed card action bar · brief header · mobile share widget', + <> + LinkIcon + , + <> + CopyIcon + , + share_copy_icon, + ], + ]} + /> + + +
+ + The per-topic flag is only evaluated once the master passes, so control + users are never bucketed into the experiment. + +
+ + +
+ + One component behind every surface. icon renders a trigger + that opens the network popover on desktop and goes straight to the + native share sheet on mobile; inline drops the network row + into the page. + + + + + + + + + + + + + + + + + Button variants and sizes are props, so each surface can match its own + control group. These are the combinations actually used. + + + + + + + + + + + + + + + +
+ +
+ + Below laptop there is no popover at all: one tap calls{' '} + navigator.share, or copies when native share is + unavailable. The frames below are the real story rendered at 390px, so + this is the actual mobile branch and not a mock-up. + +
+
+
+ 390px — mobile +
+ +
+
+ + + + + + + +
+
+
+ +
+ + The control reserves the slot with an invisible Edit button + purely to hold the row height. The experiment fills that slot instead of + leaving a dead placeholder, so the header height is unchanged. + + + + + + + + + + + + + + + + + +
+ +
+ + The owner keeps Edit profile; share sits beside it. Today a signed-in + user on desktop has no way to share their own profile at all — the + “Public profile & URL” widget is laptop:hidden. + + + + + + + + + + + + + + + + + +
+ +
+ + The bar only shows the control while it is pinned. Unpinned, the profile + card right below owns it, and two identical copy buttons on one screen + read as a mistake. Share is also promoted out of this bar's ⋯ menu. + + + This bar is a mobile surface, so each frame below is the real story at + 390px — at desktop width it would show controls (Edit profile) that a + phone never gets. + + + + + + + + + + + + + + + + + +
+ +
+ + Click each trigger. shareProps stays required; the new{' '} + hideShare prop drops just the entry, so no other call site + changes. + + + + + + + + + +
+ +
+ + A separate flag, because it touches a core high-traffic glyph on three + surfaces at once: the feed card action bar, the brief post header and + the mobile share widget. Control keeps LinkIcon. + + + +
+
+
+ +
+
+
+
+
+ +
+ + Both label variants are asserted in ProfileShareButton.spec + . The share text is what lands in the tweet / WhatsApp message / email + subject. + +
Share @idoshamun's profile, + 'Check out Ido Shamun’s profile on daily.dev', + ], + [ + 'Own profile', + Share your profile, + 'Check out my profile on daily.dev', + ], + [ + 'Copy chip, idle → copied', + Copy link → Copied!, + 'Link is shortened through dly.to with the ShareProfile campaign', + ], + ]} + /> + + + + + + + + + + + + + + +
+
+ + +); + +const meta: Meta = { + title: 'Components/Share/Overview', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Sharing-visibility review page: every surface and state the share PRs touch, flag-off next to flag-on.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const StatesAndVariants: Story = { + render: () => ( + + + + ), +}; + +/** + * Rendered at 390px inside section 04. Also useful on its own with the + * Storybook viewport toolbar. + */ +export const MobileProfileHeader: Story = { + render: () => ( + + ), + decorators: [withShareProviders(true, 'w-full')], +}; + +/** The four pinned-bar states, each embedded at 390px in section 07. */ +export const MobilePinnedBar: Story = { + render: () => ( + + ), + decorators: [withShareProviders(true, 'w-full')], +}; + +export const MobileUnpinnedBar: Story = { + render: () => , + decorators: [withShareProviders(true, 'w-full')], +}; + +export const MobilePinnedBarControl: Story = { + render: () => ( + + ), + decorators: [withShareProviders(false, 'w-full')], +}; + +export const MobilePinnedBarOwn: Story = { + render: () => , + decorators: [withShareProviders(true, 'w-full')], +}; diff --git a/packages/storybook/stories/share/reviewLayout.tsx b/packages/storybook/stories/share/reviewLayout.tsx new file mode 100644 index 00000000000..471d9faa14c --- /dev/null +++ b/packages/storybook/stories/share/reviewLayout.tsx @@ -0,0 +1,333 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; + +/** + * Presentational scaffolding for the sharing-visibility review page. Uses the + * daily.dev theme CSS variables so it follows Storybook's light/dark toggle, + * and stays provider-free so it can wrap live components without adding + * context of its own. + */ + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +export const Page = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +export const PageHeader = ({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children?: ReactNode; +}): ReactElement => ( +
+
+ {eyebrow} +
+

+ {title} +

+ {children && ( +
+ {children} +
+ )} +
+); + +export const Section = ({ + n, + title, + badge, + children, +}: { + n: string; + title: string; + badge?: string; + children: ReactNode; +}): ReactElement => ( +
+
+ + {n} + +

{title}

+ {badge && {badge}} +
+ {children} +
+); + +export const Muted = ({ + children, + style, +}: { + children: ReactNode; + style?: React.CSSProperties; +}): ReactElement => ( +

+ {children} +

+); + +export const Code = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +type Tone = 'neutral' | 'on' | 'off'; + +const toneColor: Record = { + neutral: 'var(--theme-text-tertiary)', + on: 'var(--theme-accent-avocado-default)', + off: 'var(--theme-text-quaternary)', +}; + +/** + * A single labelled specimen: caption on top, the live component on a surface + * below. `tone` colours the caption rule so flag-on / flag-off columns read at + * a glance. + */ +export const Specimen = ({ + label, + note, + tone = 'neutral', + align = 'center', + padded = true, + children, +}: { + label: string; + note?: ReactNode; + tone?: Tone; + align?: 'center' | 'start'; + padded?: boolean; + children: ReactNode; +}): ReactElement => ( +
+
+ {label} +
+
+ {children} +
+ {note && ( +
+ {note} +
+ )} +
+); + +export const Grid = ({ + cols = 2, + gap = 24, + children, +}: { + cols?: number; + gap?: number; + children: ReactNode; +}): ReactElement => ( +
+ {children} +
+); + +const cell: React.CSSProperties = { + padding: '9px 12px', + fontSize: 13, + textAlign: 'left', + verticalAlign: 'top', + borderBottom: '1px solid var(--theme-divider-tertiary)', + color: 'var(--theme-text-secondary)', +}; + +export const Table = ({ + columns, + rows, +}: { + columns: string[]; + rows: ReactNode[][]; +}): ReactElement => ( +
+
+ + + {columns.map((c) => ( + + ))} + + + + {rows.map((r, ri) => ( + // eslint-disable-next-line react/no-array-index-key + + {r.map((c, ci) => ( + + ))} + + ))} + +
+ {c} +
+ {c} +
+
+); + +/** + * 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 => ( +
+