diff --git a/projects/js-packages/publicize-components/changelog/add-social-new-media-selector-ui b/projects/js-packages/publicize-components/changelog/add-social-new-media-selector-ui new file mode 100644 index 0000000000000..a50d0ab3c66c5 --- /dev/null +++ b/projects/js-packages/publicize-components/changelog/add-social-new-media-selector-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add the new media selection UI for Social diff --git a/projects/js-packages/publicize-components/src/components/form/share-post-form.tsx b/projects/js-packages/publicize-components/src/components/form/share-post-form.tsx index 5a05467bd5b2f..219abb58404a2 100644 --- a/projects/js-packages/publicize-components/src/components/form/share-post-form.tsx +++ b/projects/js-packages/publicize-components/src/components/form/share-post-form.tsx @@ -5,6 +5,7 @@ import useSocialMediaMessage from '../../hooks/use-social-media-message'; import { features } from '../../utils/constants'; import { useIsSocialNote } from '../../utils/use-is-social-note'; import MediaSection from '../media-section'; +import MediaSectionV2 from '../media-section-v2'; import MessageBoxControl from '../message-box-control'; import SocialImageGeneratorPanel from '../social-image-generator/panel'; import styles from './styles.module.scss'; @@ -41,15 +42,20 @@ export const SharePostForm: FC< SharePostFormProps > = ( { analyticsData = null /> ) } { siteHasFeature( features.UNIFIED_UI_V1 ) ? ( -
HERE GOES THE NEW MEDIA SECTION
- ) : null } - { siteHasFeature( features.ENHANCED_PUBLISHING ) && (
- +
+ ) : ( + <> + { siteHasFeature( features.ENHANCED_PUBLISHING ) && ( +
+ +
+ ) } + { /* Social Image Generator panel - only shown when not using unified UI */ } + { postCanUseSig && } + ) } - { /* Social Image Generator panel */ } - { postCanUseSig && } ); }; diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/custom-media-toggle.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/custom-media-toggle.tsx new file mode 100644 index 0000000000000..892f8120af2ed --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/custom-media-toggle.tsx @@ -0,0 +1,65 @@ +/** + * CustomMediaToggle component + * Toggle for sharing image as attachment instead of link preview + */ + +import { ToggleControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { getAttachmentDescription } from './media-source-menu'; +import styles from './styles.module.scss'; +import { MediaSourceType } from './types'; + +interface CustomMediaToggleProps { + /** + * Current media source type + */ + source: MediaSourceType; + + /** + * Whether the toggle is checked (attached media is set) + */ + checked: boolean; + + /** + * Callback when toggle changes + */ + onChange: ( checked: boolean ) => void; + + /** + * Whether the toggle is disabled + */ + disabled?: boolean; +} + +/** + * Toggle for sharing image as attachment + * + * @param {CustomMediaToggleProps} props - Component props + * @return {JSX.Element|null} CustomMediaToggle component + */ +export default function CustomMediaToggle( { + source, + checked, + onChange, + disabled = false, +}: CustomMediaToggleProps ) { + const description = getAttachmentDescription( source ); + + // Only show for sources that have an attachment description (featured-image, sig) + if ( ! description ) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx new file mode 100644 index 0000000000000..c0a22db7b2757 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/index.tsx @@ -0,0 +1,332 @@ +/** + * MediaSectionV2 component + * Unified media selection interface for social posts + */ + +import { ThemeProvider } from '@automattic/jetpack-components'; +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { MediaUpload } from '@wordpress/block-editor'; +import { BaseControl, Button, Notice } from '@wordpress/components'; +import { useCallback, useMemo, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import useFeaturedImage from '../../hooks/use-featured-image'; +import useImageGeneratorConfig from '../../hooks/use-image-generator-config'; +import useMediaDetails from '../../hooks/use-media-details'; +import { SELECTABLE_MEDIA_TYPES } from '../../hooks/use-media-restrictions/restrictions'; +import { usePostMeta } from '../../hooks/use-post-meta'; +import useSigPreview from '../../hooks/use-sig-preview'; +import CustomMediaToggle from './custom-media-toggle'; +import MediaPreview from './media-preview'; +import MediaSourceMenu, { getMediaSourceDescription } from './media-source-menu'; +import styles from './styles.module.scss'; +import { MediaSourceType, MediaSectionV2Props, MediaPreviewData, WPMediaObject } from './types'; + +/** + * Detect the current media source based on existing data (for backward compatibility) + * + * @param {Array} attachedMedia - Attached media array + * @param {number} featuredImageId - Featured image ID + * @param {boolean} sigEnabled - Whether SIG is enabled + * @return {string|null} Current media source type + */ +function detectMediaSource( + attachedMedia: Array< { id: number; url: string; type: string } >, + featuredImageId: number | null, + sigEnabled: boolean +): MediaSourceType { + // Priority 1: Attached media (uploaded content) + if ( attachedMedia && attachedMedia.length > 0 ) { + // Check if attached media is the featured image (shared as attachment) + if ( featuredImageId && attachedMedia[ 0 ].id === featuredImageId ) { + return 'featured-image'; + } + // Check if it's SIG in attachment mode (id=0 with SIG enabled) + if ( sigEnabled && attachedMedia[ 0 ].id === 0 ) { + return 'sig'; + } + return attachedMedia[ 0 ].type?.startsWith( 'video/' ) ? 'upload-video' : 'media-library'; + } + + // Priority 2: Social Image Generator + if ( sigEnabled ) { + return 'sig'; + } + + // Priority 3: Featured Image + if ( featuredImageId ) { + return 'featured-image'; + } + + // No media selected + return null; +} + +/** + * MediaSectionV2 component + * + * @param {object} props - Component props + * @param {object} props.analyticsData - Analytics data + * @param {boolean} props.disabled - Whether the section is disabled + * @return {object} MediaSectionV2 component + */ +export default function MediaSectionV2( { + analyticsData = {}, + disabled = false, +}: MediaSectionV2Props ) { + const { recordEvent } = useAnalytics(); + const featuredImageId = useFeaturedImage(); + const { isEnabled: sigEnabled } = useImageGeneratorConfig(); + const { attachedMedia, imageGeneratorSettings, mediaSource, updateJetpackSocialOptions } = + usePostMeta(); + + // Get SIG preview URL when SIG is enabled + const { url: sigPreviewUrl, isLoading: sigIsLoading } = useSigPreview( sigEnabled ); + + // Ref to store the MediaUpload open function + const openMediaLibraryRef = useRef< () => void >( () => {} ); + + // Determine current media source + // Priority 1: Explicit user choice (if media_source is set) + // Priority 2: Detect from existing data (backward compatibility) + const currentSource = useMemo( () => { + if ( mediaSource !== undefined ) { + return mediaSource === 'none' ? null : ( mediaSource as MediaSourceType ); + } + return detectMediaSource( attachedMedia, featuredImageId, sigEnabled ); + }, [ mediaSource, attachedMedia, featuredImageId, sigEnabled ] ); + + // Attachment mode: check if attached_media has items (matches backend is_social_post()) + const isShareAsAttachment = attachedMedia?.length > 0; + + // Get media ID for preview + const mediaId = useMemo( () => { + if ( currentSource === 'featured-image' ) { + return featuredImageId; + } + if ( currentSource === 'media-library' || currentSource === 'upload-video' ) { + return attachedMedia?.[ 0 ]?.id; + } + return null; + }, [ currentSource, featuredImageId, attachedMedia ] ); + + const [ mediaDetails ] = useMediaDetails( mediaId ); + + const previewData: MediaPreviewData | null = useMemo( () => { + // Use SIG preview URL when SIG is selected + // Always return an object (even with empty URL) so the loading spinner can show + if ( currentSource === 'sig' ) { + return { + id: 0, + url: sigPreviewUrl || '', + type: 'image', + }; + } + + if ( ! mediaId || ! mediaDetails?.mediaData ) { + return null; + } + + const { sourceUrl } = mediaDetails.mediaData; + const { mime } = mediaDetails.metaData || {}; + + return { + id: mediaId, + url: sourceUrl, + type: mime?.startsWith( 'video/' ) ? 'video' : 'image', + }; + }, [ currentSource, mediaId, mediaDetails, sigPreviewUrl ] ); + + // Handle media source selection from dropdown + const handleSourceSelect = useCallback( + ( source: MediaSourceType ) => { + recordEvent( 'jetpack_social_media_source_changed', { + ...analyticsData, + source, + } ); + + // Single batch update with explicit media_source and all related fields + updateJetpackSocialOptions( { + media_source: source || 'none', + attached_media: [], // Reset attachment when changing source + image_generator_settings: { + ...imageGeneratorSettings, + enabled: source === 'sig', + }, + } ); + }, + [ recordEvent, analyticsData, updateJetpackSocialOptions, imageGeneratorSettings ] + ); + + // Handle media selection from Media Library + const handleMediaLibrarySelect = useCallback( + ( media: WPMediaObject ) => { + const { id, url, mime: type } = media; + + // Single batch update with explicit media_source + updateJetpackSocialOptions( { + media_source: 'media-library', + attached_media: [ { id, url, type } ], + image_generator_settings: { ...imageGeneratorSettings, enabled: false }, + } ); + + recordEvent( 'jetpack_social_media_source_changed', { + ...analyticsData, + source: 'media-library', + } ); + }, + [ updateJetpackSocialOptions, imageGeneratorSettings, recordEvent, analyticsData ] + ); + + const handleMediaLibraryClick = useCallback( () => { + setTimeout( () => { + openMediaLibraryRef.current(); + }, 0 ); + }, [] ); + + const renderMediaUpload = useCallback( ( { open }: { open: () => void } ) => { + openMediaLibraryRef.current = open; + return null; + }, [] ); + + // Handle remove - go to "no image" state + const handleRemove = useCallback( () => { + // Single batch update with explicit 'none' source + updateJetpackSocialOptions( { + media_source: 'none', + attached_media: [], + image_generator_settings: { ...imageGeneratorSettings, enabled: false }, + } ); + + recordEvent( 'jetpack_social_media_removed', { + ...analyticsData, + source: currentSource, + } ); + }, [ + updateJetpackSocialOptions, + imageGeneratorSettings, + recordEvent, + analyticsData, + currentSource, + ] ); + + // Handle attachment toggle change + const handleAttachmentToggle = useCallback( + ( checked: boolean ) => { + if ( currentSource === 'featured-image' && previewData ) { + // Featured image: toggle attachment mode + updateJetpackSocialOptions( { + media_source: 'featured-image', + attached_media: checked + ? [ { id: previewData.id, url: previewData.url, type: 'image/jpeg' } ] + : [], + } ); + } else if ( currentSource === 'sig' && sigPreviewUrl ) { + // SIG: toggle attachment mode (add SIG URL to attached_media) + updateJetpackSocialOptions( { + media_source: 'sig', + attached_media: checked ? [ { id: 0, url: sigPreviewUrl, type: 'image/jpeg' } ] : [], + // Keep SIG enabled regardless + image_generator_settings: { ...imageGeneratorSettings, enabled: true }, + } ); + } + + recordEvent( + checked + ? 'jetpack_social_share_as_attachment_enabled' + : 'jetpack_social_share_as_attachment_disabled', + { + ...analyticsData, + source: currentSource, + } + ); + }, + [ + currentSource, + previewData, + sigPreviewUrl, + updateJetpackSocialOptions, + imageGeneratorSettings, + recordEvent, + analyticsData, + ] + ); + + return ( + +
+ + + { __( 'Media', 'jetpack-publicize-components' ) } + +

{ getMediaSourceDescription( currentSource ) }

+ + { /* MediaUpload component - rendered once, open function stored in ref */ } + + + { /* Show dropdown + preview when there's media */ } + { previewData && ( + <> + + { ( { open } ) => ( + + ) } + + { currentSource === 'sig' && ( + + ) } + + + ) } + + { /* Show dropdown when no media */ } + { ! previewData && ( + <> + + { currentSource === 'featured-image' && ! featuredImageId && ( + + { __( + 'Your post does not have a featured image.', + 'jetpack-publicize-components' + ) } + + ) } + + ) } +
+
+
+ ); +} diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/media-preview.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/media-preview.tsx new file mode 100644 index 0000000000000..23496076a6b5b --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/media-preview.tsx @@ -0,0 +1,72 @@ +/** + * MediaPreview component + * Displays media preview + */ + +import { + Button, + Spinner, + __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import styles from './styles.module.scss'; +import { MediaPreviewProps } from './types'; + +/** + * MediaPreview component matching WordPress core featured image pattern + * + * @param {MediaPreviewProps} props - Component props + * @return {JSX.Element|null} MediaPreview component + */ +export default function MediaPreview( { + media, + isLoading = false, + onReplace, + onRemove, + disabled = false, +}: MediaPreviewProps ) { + if ( ! media && ! isLoading ) { + return null; + } + + return ( +
+
+ { media && + ! isLoading && + ( media.type === 'video' ? ( + + ) : ( + { + ) ) } + { isLoading && } +
+ { media && ! isLoading && ( + + + + + ) } +
+ ); +} diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/media-source-menu.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/media-source-menu.tsx new file mode 100644 index 0000000000000..30a78ac6b4871 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/media-source-menu.tsx @@ -0,0 +1,216 @@ +/** + * MediaSourceMenu component + * Displays a dropdown menu with grouped media source options + */ + +import { Button, Dropdown, MenuGroup, MenuItem } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { image, video, starEmpty, media as mediaIcon } from '@wordpress/icons'; +import styles from './styles.module.scss'; +import { MediaSourceMenuProps, MediaSourceOption, MediaSourceType } from './types'; + +/** + * Available media source options with their metadata + */ +const MEDIA_SOURCE_OPTIONS: MediaSourceOption[] = [ + { + id: 'featured-image', + label: __( 'Featured Image', 'jetpack-publicize-components' ), + description: __( 'You are using your post featured image', 'jetpack-publicize-components' ), + icon: image, + group: 'link-preview', + attachmentDescription: __( + 'Shares your image as a regular post, without a link preview card, for higher engagement.', + 'jetpack-publicize-components' + ), + }, + { + id: 'sig', + label: __( 'Social Image Template', 'jetpack-publicize-components' ), + description: __( 'You are using the template', 'jetpack-publicize-components' ), + icon: starEmpty, + group: 'link-preview', + attachmentDescription: __( + 'Shares your template as an attached image, without a link preview card, for higher engagement.', + 'jetpack-publicize-components' + ), + }, + { + id: 'media-library', + label: __( 'Media Library', 'jetpack-publicize-components' ), + description: __( 'You are using a custom image.', 'jetpack-publicize-components' ), + icon: mediaIcon, + group: 'attachment', + }, + { + id: 'upload-video', + label: __( 'Upload video', 'jetpack-publicize-components' ), + description: __( 'Upload a video file', 'jetpack-publicize-components' ), + icon: video, + group: 'attachment', + }, +]; + +/** + * Get the description for a media source + * + * @param {string} sourceType - Media source type + * @return {string} Description for the media source + */ +export function getMediaSourceDescription( sourceType: MediaSourceType ): string { + if ( ! sourceType ) { + return __( "Your post won't show an image.", 'jetpack-publicize-components' ); + } + const option = MEDIA_SOURCE_OPTIONS.find( opt => opt.id === sourceType ); + return ( + option?.description || __( "Your post won't show an image.", 'jetpack-publicize-components' ) + ); +} + +/** + * Get the attachment toggle description for a media source + * + * @param {string} sourceType - Media source type + * @return {string|undefined} Attachment description for the media source + */ +export function getAttachmentDescription( sourceType: MediaSourceType ): string | undefined { + if ( ! sourceType ) { + return undefined; + } + const option = MEDIA_SOURCE_OPTIONS.find( opt => opt.id === sourceType ); + return option?.attachmentDescription; +} + +/** + * Props for MediaSourceMenuItem component + */ +interface MediaSourceMenuItemProps { + option: MediaSourceOption; + isSelected: boolean; + onSelect: ( optionId: MediaSourceType ) => void; + onClose: () => void; + onMediaLibraryClick?: () => void; +} + +/** + * MediaSourceMenuItem component + * + * @param {object} props - Component props + * @param {object} props.option - Menu option data + * @param {boolean} props.isSelected - Whether this option is selected + * @param {Function} props.onSelect - Selection handler + * @param {Function} props.onClose - Close dropdown handler + * @param {Function} props.onMediaLibraryClick - Media library click handler + * @return {object} MediaSourceMenuItem component + */ +function MediaSourceMenuItem( { + option, + isSelected, + onSelect, + onClose, + onMediaLibraryClick, +}: MediaSourceMenuItemProps ) { + const handleClick = useCallback( () => { + if ( option.id === 'media-library' ) { + onMediaLibraryClick?.(); + } else { + onSelect( option.id ); + } + onClose(); + }, [ option.id, onSelect, onClose, onMediaLibraryClick ] ); + + return ( + + { option.label } + + ); +} + +/** + * MediaSourceMenu component + * + * @param {object} props - Component props + * @param {string} props.currentSource - Currently selected media source + * @param {Function} props.onSelect - Callback when a source is selected + * @param {Function} props.onMediaLibraryClick - Callback when Media Library option is clicked + * @param {boolean} props.disabled - Whether the menu is disabled + * @param {Function} props.children - Optional children render function that receives open function + * @return {object} MediaSourceMenu component + */ +export default function MediaSourceMenu( { + currentSource, + onSelect, + onMediaLibraryClick, + disabled = false, + children, +}: MediaSourceMenuProps ) { + // Group options by category + const linkPreviewOptions = MEDIA_SOURCE_OPTIONS.filter( opt => opt.group === 'link-preview' ); + const attachmentOptions = MEDIA_SOURCE_OPTIONS.filter( opt => opt.group === 'attachment' ); + + const renderToggle = useCallback( + ( { isOpen, onToggle }: { isOpen: boolean; onToggle: () => void } ) => ( + <> + { ! children && ( + + ) } + { children && children( { open: onToggle } ) } + + ), + [ children, disabled ] + ); + + const renderContent = useCallback( + ( { onClose }: { onClose: () => void } ) => ( + <> + + { linkPreviewOptions.map( option => ( + + ) ) } + + + { attachmentOptions.map( option => ( + + ) ) } + + + ), + [ linkPreviewOptions, attachmentOptions, currentSource, onSelect, onMediaLibraryClick ] + ); + + return ( + + ); +} diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/styles.module.scss b/projects/js-packages/publicize-components/src/components/media-section-v2/styles.module.scss new file mode 100644 index 0000000000000..c61fd7a35e009 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/styles.module.scss @@ -0,0 +1,86 @@ +/** + * Styles for the unified media section + * Featured image styles adapted from @wordpress/editor post-featured-image + */ + +.media-section { + margin-bottom: 16px; +} + +.description { + color: #757575; + margin: 8px 0 8px; + font-size: 13px; + line-height: 1.4; +} + +.selectButton { + width: 100%; + justify-content: center; + margin-top: 6px; +} + +.dropdownMenu { + width: 100%; +} + +// Featured image styles (copied from Gutenberg for stability) +.container { + position: relative; + + &:hover, + &:focus, + &:focus-within { + + .actions { + opacity: 1; + } + } +} + +.preview { + width: 100%; + padding: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + outline: 1px solid rgba(0, 0, 0, 0.1); + position: relative; + aspect-ratio: 2/1; + + .components-spinner { + position: absolute; + } +} + +.previewImage { + object-fit: cover; + width: 100%; + height: 100%; + object-position: 50% 50%; +} + +.actions { + bottom: 0; + opacity: 0; + padding: 8px; + position: absolute; + transition: opacity 50ms ease-out; + width: 100%; + + .action { + flex-grow: 1; + justify-content: center; + backdrop-filter: blur(16px) saturate(180%); + background: rgba(255, 255, 255, 0.75); + } +} + +.custom-media-toggle { + margin-top: 16px; +} + +.notice { + margin-top: 12px; +} diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/test/index.test.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/test/index.test.tsx new file mode 100644 index 0000000000000..0bc0f537dfbf1 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/test/index.test.tsx @@ -0,0 +1,309 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MediaSectionV2 from '..'; +import useFeaturedImage from '../../../hooks/use-featured-image'; +import useImageGeneratorConfig from '../../../hooks/use-image-generator-config'; +import useMediaDetails from '../../../hooks/use-media-details'; +import { usePostMeta } from '../../../hooks/use-post-meta'; +import useSigPreview from '../../../hooks/use-sig-preview'; + +// Mock functions +const mockUpdateJetpackSocialOptions = jest.fn(); +const mockRecordEvent = jest.fn(); + +jest.mock( '../../../hooks/use-featured-image', () => { + return jest.fn( () => 123 ); +} ); + +jest.mock( '../../../hooks/use-image-generator-config', () => { + return jest.fn( () => ( { + isEnabled: false, + setIsEnabled: jest.fn(), + } ) ); +} ); + +jest.mock( '../../../hooks/use-media-details', () => { + return jest.fn( () => [ + { + mediaData: { + sourceUrl: 'https://example.com/featured.jpg', + }, + metaData: { + mime: 'image/jpeg', + }, + }, + ] ); +} ); + +jest.mock( '../../../hooks/use-post-meta', () => ( { + usePostMeta: jest.fn( () => ( { + attachedMedia: [], + imageGeneratorSettings: { enabled: false }, + mediaSource: undefined, + updateJetpackSocialOptions: mockUpdateJetpackSocialOptions, + } ) ), +} ) ); + +jest.mock( '../../../hooks/use-sig-preview', () => { + return jest.fn( () => ( { + url: 'https://example.com/sig-preview.jpg', + isLoading: false, + } ) ); +} ); + +jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { + useAnalytics: () => ( { + recordEvent: mockRecordEvent, + } ), +} ) ); + +jest.mock( '@wordpress/block-editor', () => ( { + MediaUpload: ( { + render: renderProp, + onSelect, + }: { + render: ( props: { open: () => void } ) => React.ReactNode; + onSelect: ( media: unknown ) => void; + } ) => { + const open = () => { + onSelect( { + id: 456, + url: 'https://example.com/selected.jpg', + mime: 'image/jpeg', + } ); + }; + return renderProp( { open } ); + }, +} ) ); + +describe( 'MediaSectionV2', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'Initial rendering', () => { + it( 'should render the Media label', () => { + render( ); + + expect( screen.getByText( 'Media' ) ).toBeInTheDocument(); + } ); + + it( 'should show featured image description when featured image is detected', () => { + render( ); + + expect( screen.getByText( 'You are using your post featured image' ) ).toBeInTheDocument(); + } ); + + it( 'should show featured image preview', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img ).toBeInTheDocument(); + expect( img ).toHaveAttribute( 'src', 'https://example.com/featured.jpg' ); + } ); + } ); + + describe( 'No media state', () => { + beforeEach( () => { + ( useFeaturedImage as jest.Mock ).mockReturnValue( null ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ null ] ); + } ); + + afterEach( () => { + ( useFeaturedImage as jest.Mock ).mockReturnValue( 123 ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ + { + mediaData: { sourceUrl: 'https://example.com/featured.jpg' }, + metaData: { mime: 'image/jpeg' }, + }, + ] ); + } ); + + it( 'should show "no image" description when no media source is selected', () => { + render( ); + + expect( screen.getByText( "Your post won't show an image." ) ).toBeInTheDocument(); + } ); + + it( 'should show Select button when no media', () => { + render( ); + + expect( screen.getByRole( 'button', { name: 'Select' } ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'Attached media state', () => { + beforeEach( () => { + ( usePostMeta as jest.Mock ).mockReturnValue( { + attachedMedia: [ { id: 789, url: 'https://example.com/attached.jpg', type: 'image/jpeg' } ], + imageGeneratorSettings: { enabled: false }, + mediaSource: 'media-library', + updateJetpackSocialOptions: mockUpdateJetpackSocialOptions, + } ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ + { + mediaData: { sourceUrl: 'https://example.com/attached.jpg' }, + metaData: { mime: 'image/jpeg' }, + }, + ] ); + } ); + + afterEach( () => { + ( usePostMeta as jest.Mock ).mockReturnValue( { + attachedMedia: [], + imageGeneratorSettings: { enabled: false }, + mediaSource: undefined, + updateJetpackSocialOptions: mockUpdateJetpackSocialOptions, + } ); + } ); + + it( 'should show custom image description when attached media exists', () => { + render( ); + + expect( screen.getByText( 'You are using a custom image.' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'SIG enabled state', () => { + beforeEach( () => { + ( useImageGeneratorConfig as jest.Mock ).mockReturnValue( { + isEnabled: true, + setIsEnabled: jest.fn(), + } ); + } ); + + afterEach( () => { + ( useImageGeneratorConfig as jest.Mock ).mockReturnValue( { + isEnabled: false, + setIsEnabled: jest.fn(), + } ); + } ); + + it( 'should show SIG description when SIG is enabled', () => { + render( ); + + expect( screen.getByText( 'You are using the template' ) ).toBeInTheDocument(); + } ); + + it( 'should show SIG preview image', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img ).toHaveAttribute( 'src', 'https://example.com/sig-preview.jpg' ); + } ); + + it( 'should not show SIG preview image when loading', () => { + ( useSigPreview as jest.Mock ).mockReturnValue( { + url: '', + isLoading: true, + } ); + + render( ); + + // When SIG is loading, the preview image should not be visible yet + expect( screen.queryByRole( 'img' ) ).not.toBeInTheDocument(); + + // Reset + ( useSigPreview as jest.Mock ).mockReturnValue( { + url: 'https://example.com/sig-preview.jpg', + isLoading: false, + } ); + } ); + } ); + + describe( 'Source selection', () => { + it( 'should call updateJetpackSocialOptions when selecting SIG', async () => { + const user = userEvent.setup(); + + render( ); + + // Open dropdown + await user.click( screen.getByRole( 'button', { name: 'Replace' } ) ); + + // Select SIG + await user.click( screen.getByRole( 'menuitem', { name: 'Social Image Template' } ) ); + + expect( mockUpdateJetpackSocialOptions ).toHaveBeenCalledWith( { + media_source: 'sig', + attached_media: [], + image_generator_settings: { enabled: true }, + } ); + } ); + + it( 'should call updateJetpackSocialOptions when selecting Featured Image', async () => { + const user = userEvent.setup(); + + // Start with SIG enabled + ( useImageGeneratorConfig as jest.Mock ).mockReturnValue( { + isEnabled: true, + setIsEnabled: jest.fn(), + } ); + + render( ); + + // Open dropdown + await user.click( screen.getByRole( 'button', { name: 'Replace' } ) ); + + // Select Featured Image + await user.click( screen.getByRole( 'menuitem', { name: 'Featured Image' } ) ); + + expect( mockUpdateJetpackSocialOptions ).toHaveBeenCalledWith( { + media_source: 'featured-image', + attached_media: [], + image_generator_settings: { enabled: false }, + } ); + + // Reset + ( useImageGeneratorConfig as jest.Mock ).mockReturnValue( { + isEnabled: false, + setIsEnabled: jest.fn(), + } ); + } ); + + it( 'should record analytics event when source is changed', async () => { + const user = userEvent.setup(); + + render( ); + + // Open dropdown + await user.click( screen.getByRole( 'button', { name: 'Replace' } ) ); + + // Select SIG + await user.click( screen.getByRole( 'menuitem', { name: 'Social Image Template' } ) ); + + expect( mockRecordEvent ).toHaveBeenCalledWith( 'jetpack_social_media_source_changed', { + test: 'data', + source: 'sig', + } ); + } ); + } ); + + describe( 'Remove media', () => { + it( 'should clear media and record event when Remove is clicked', async () => { + const user = userEvent.setup(); + + render( ); + + await user.click( screen.getByRole( 'button', { name: 'Remove' } ) ); + + expect( mockUpdateJetpackSocialOptions ).toHaveBeenCalledWith( { + media_source: 'none', + attached_media: [], + image_generator_settings: { enabled: false }, + } ); + expect( mockRecordEvent ).toHaveBeenCalledWith( 'jetpack_social_media_removed', { + test: 'data', + source: 'featured-image', + } ); + } ); + } ); + + describe( 'Disabled state', () => { + it( 'should disable buttons when disabled prop is true', () => { + render( ); + + expect( screen.getByRole( 'button', { name: 'Replace' } ) ).toBeDisabled(); + expect( screen.getByRole( 'button', { name: 'Remove' } ) ).toBeDisabled(); + } ); + } ); +} ); diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/test/media-preview.test.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/test/media-preview.test.tsx new file mode 100644 index 0000000000000..9867738085af9 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/test/media-preview.test.tsx @@ -0,0 +1,133 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MediaPreview from '../media-preview'; + +describe( 'MediaPreview', () => { + const defaultMedia = { + id: 123, + url: 'https://example.com/image.jpg', + type: 'image' as const, + }; + + const mockOnReplace = jest.fn(); + const mockOnRemove = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should return null when no media and not loading', () => { + const { container } = render( + + ); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'should render image preview for image media', () => { + render( + + ); + + const img = screen.getByRole( 'img' ); + expect( img ).toBeInTheDocument(); + expect( img ).toHaveAttribute( 'src', defaultMedia.url ); + } ); + + it( 'should render video preview for video media', () => { + const videoMedia = { + id: 456, + url: 'https://example.com/video.mp4', + type: 'video' as const, + }; + + render( + + ); + + // When video media is passed, no img element should be rendered + expect( screen.queryByRole( 'img' ) ).not.toBeInTheDocument(); + + // But the action buttons should still be available + expect( screen.getByRole( 'button', { name: 'Replace' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Remove' } ) ).toBeInTheDocument(); + } ); + + it( 'should not show media preview when loading', () => { + render( + + ); + + // When loading, the image should not be visible + expect( screen.queryByRole( 'img' ) ).not.toBeInTheDocument(); + // And the action buttons should not be shown + expect( screen.queryByRole( 'button', { name: 'Replace' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'Remove' } ) ).not.toBeInTheDocument(); + } ); + + it( 'should render Replace and Remove buttons', () => { + render( + + ); + + expect( screen.getByRole( 'button', { name: 'Replace' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Remove' } ) ).toBeInTheDocument(); + } ); + + it( 'should call onReplace when Replace button is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Replace' } ) ); + + expect( mockOnReplace ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should call onRemove when Remove button is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Remove' } ) ); + + expect( mockOnRemove ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should disable buttons when disabled prop is true', () => { + render( + + ); + + expect( screen.getByRole( 'button', { name: 'Replace' } ) ).toBeDisabled(); + expect( screen.getByRole( 'button', { name: 'Remove' } ) ).toBeDisabled(); + } ); + + it( 'should not show buttons when loading', () => { + render( + + ); + + expect( screen.queryByRole( 'button', { name: 'Replace' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'Remove' } ) ).not.toBeInTheDocument(); + } ); +} ); diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/test/media-source-menu.test.tsx b/projects/js-packages/publicize-components/src/components/media-section-v2/test/media-source-menu.test.tsx new file mode 100644 index 0000000000000..d986c60b49d89 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/test/media-source-menu.test.tsx @@ -0,0 +1,191 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MediaSourceMenu, { getMediaSourceDescription } from '../media-source-menu'; + +describe( 'getMediaSourceDescription', () => { + it( 'should return default message when sourceType is null', () => { + expect( getMediaSourceDescription( null ) ).toBe( "Your post won't show an image." ); + } ); + + it( 'should return featured image description', () => { + expect( getMediaSourceDescription( 'featured-image' ) ).toBe( + 'You are using your post featured image' + ); + } ); + + it( 'should return SIG description', () => { + expect( getMediaSourceDescription( 'sig' ) ).toBe( 'You are using the template' ); + } ); + + it( 'should return media library description', () => { + expect( getMediaSourceDescription( 'media-library' ) ).toBe( 'You are using a custom image.' ); + } ); + + it( 'should return upload video description', () => { + expect( getMediaSourceDescription( 'upload-video' ) ).toBe( 'Upload a video file' ); + } ); +} ); + +describe( 'MediaSourceMenu', () => { + const mockOnSelect = jest.fn(); + const mockOnMediaLibraryClick = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should render Select button when no children provided', () => { + render( + + ); + + expect( screen.getByRole( 'button', { name: 'Select' } ) ).toBeInTheDocument(); + } ); + + it( 'should disable Select button when disabled prop is true', () => { + render( + + ); + + expect( screen.getByRole( 'button', { name: 'Select' } ) ).toBeDisabled(); + } ); + + it( 'should open dropdown menu when Select button is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Select' } ) ); + + // Check that menu groups are rendered + expect( screen.getByText( 'Link Preview' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Attachment' ) ).toBeInTheDocument(); + + // Check that menu items are rendered + expect( screen.getByRole( 'menuitem', { name: 'Featured Image' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'menuitem', { name: 'Social Image Template' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'menuitem', { name: 'Media Library' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'menuitem', { name: 'Upload video' } ) ).toBeInTheDocument(); + } ); + + it( 'should call onSelect when Featured Image is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Select' } ) ); + await user.click( screen.getByRole( 'menuitem', { name: 'Featured Image' } ) ); + + expect( mockOnSelect ).toHaveBeenCalledWith( 'featured-image' ); + } ); + + it( 'should call onSelect when Social Image Template is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Select' } ) ); + await user.click( screen.getByRole( 'menuitem', { name: 'Social Image Template' } ) ); + + expect( mockOnSelect ).toHaveBeenCalledWith( 'sig' ); + } ); + + it( 'should call onMediaLibraryClick when Media Library is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Select' } ) ); + await user.click( screen.getByRole( 'menuitem', { name: 'Media Library' } ) ); + + expect( mockOnMediaLibraryClick ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).not.toHaveBeenCalled(); + } ); + + it( 'should render children with open function when provided', async () => { + const user = userEvent.setup(); + const mockChildren = jest.fn( ( { open } ) => ( + + ) ); + + render( + + { mockChildren } + + ); + + // Select button should not be rendered + expect( screen.queryByRole( 'button', { name: 'Select' } ) ).not.toBeInTheDocument(); + + // Custom trigger should be rendered + expect( screen.getByRole( 'button', { name: 'Custom Trigger' } ) ).toBeInTheDocument(); + + // Children should receive open function + expect( mockChildren ).toHaveBeenCalledWith( + expect.objectContaining( { open: expect.any( Function ) } ) + ); + + // Clicking custom trigger should open dropdown + await user.click( screen.getByRole( 'button', { name: 'Custom Trigger' } ) ); + expect( screen.getByText( 'Link Preview' ) ).toBeInTheDocument(); + } ); + + it( 'should render menu with current source item', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( screen.getByRole( 'button', { name: 'Select' } ) ); + + // Verify the menu renders with all options including the current source + const featuredImageItem = screen.getByRole( 'menuitem', { name: 'Featured Image' } ); + expect( featuredImageItem ).toBeInTheDocument(); + + // Verify clicking the current source still triggers onSelect + await user.click( featuredImageItem ); + expect( mockOnSelect ).toHaveBeenCalledWith( 'featured-image' ); + } ); +} ); diff --git a/projects/js-packages/publicize-components/src/components/media-section-v2/types.ts b/projects/js-packages/publicize-components/src/components/media-section-v2/types.ts new file mode 100644 index 0000000000000..6598b13f36c06 --- /dev/null +++ b/projects/js-packages/publicize-components/src/components/media-section-v2/types.ts @@ -0,0 +1,120 @@ +/** + * Types for the unified media section component + */ + +/** + * Media source types + */ +export type MediaSourceType = 'featured-image' | 'media-library' | 'upload-video' | 'sig' | null; + +/** + * WordPress media object from MediaUpload + */ +export interface WPMediaObject { + id: number; + url: string; + mime: string; +} + +/** + * Menu group types for categorizing media sources + */ +export type MenuGroupType = 'link-preview' | 'attachment'; + +/** + * Media source option definition + */ +export interface MediaSourceOption { + id: MediaSourceType; + label: string; + description: string; + icon: JSX.Element; + group: MenuGroupType; + attachmentDescription?: string; +} + +/** + * Media preview data structure + */ +export interface MediaPreviewData { + id: number; + url: string; + type: 'image' | 'video'; + width?: number; + height?: number; +} + +/** + * Props for MediaSectionV2 component + */ +export interface MediaSectionV2Props { + /** + * Analytics data to be passed to tracking events + */ + analyticsData?: Record< string, unknown >; + + /** + * Whether the section is disabled + */ + disabled?: boolean; +} + +/** + * Props for MediaSourceMenu component + */ +export interface MediaSourceMenuProps { + /** + * Currently selected media source + */ + currentSource: MediaSourceType; + + /** + * Callback when a media source is selected + */ + onSelect: ( source: MediaSourceType ) => void; + + /** + * Callback when Media Library option is clicked + */ + onMediaLibraryClick?: () => void; + + /** + * Whether the menu is disabled + */ + disabled?: boolean; + + /** + * Optional children render function that receives open function + */ + children?: ( { open }: { open: () => void } ) => React.ReactNode; +} + +/** + * Props for MediaPreview component + */ +export interface MediaPreviewProps { + /** + * Media preview data + */ + media: MediaPreviewData | null; + + /** + * Whether the preview is in loading state + */ + isLoading?: boolean; + + /** + * Callback to replace the media + */ + onReplace?: () => void; + + /** + * Callback to remove the media + */ + onRemove?: () => void; + + /** + * Whether the actions are disabled + */ + disabled?: boolean; +} diff --git a/projects/js-packages/publicize-components/src/hooks/use-post-meta/index.js b/projects/js-packages/publicize-components/src/hooks/use-post-meta/index.js index ea6fe4416fc35..04c162e5e8251 100644 --- a/projects/js-packages/publicize-components/src/hooks/use-post-meta/index.js +++ b/projects/js-packages/publicize-components/src/hooks/use-post-meta/index.js @@ -30,6 +30,7 @@ export function usePostMeta() { const attachedMedia = jetpackSocialOptions.attached_media || DEFAULT_ATTACHED_MEDIA; const imageGeneratorSettings = jetpackSocialOptions.image_generator_settings ?? DEFAULT_IMAGE_GENERATOR_SETTINGS; + const mediaSource = jetpackSocialOptions.media_source; const isPostAlreadyShared = meta.jetpack_social_post_already_shared ?? false; const shareMessage = `${ meta.jetpack_publicize_message || '' }`.substring( @@ -42,6 +43,7 @@ export function usePostMeta() { jetpackSocialOptions, attachedMedia, imageGeneratorSettings, + mediaSource, isPostAlreadyShared, shareMessage, }; @@ -65,10 +67,13 @@ export function usePostMeta() { }, [ metaValues.isPublicizeEnabled, updateMeta ] ); const updateJetpackSocialOptions = useCallback( - ( key, value ) => { + ( keyOrUpdates, value ) => { + // Support both single key-value and object of updates + const updates = typeof keyOrUpdates === 'string' ? { [ keyOrUpdates ]: value } : keyOrUpdates; + updateMeta( 'jetpack_social_options', { ...metaValues.jetpackSocialOptions, - [ key ]: value, + ...updates, version: 2, } ); }, diff --git a/projects/js-packages/publicize-components/src/utils/types.ts b/projects/js-packages/publicize-components/src/utils/types.ts index 71a5b8f1dc034..1ec711ff58a20 100644 --- a/projects/js-packages/publicize-components/src/utils/types.ts +++ b/projects/js-packages/publicize-components/src/utils/types.ts @@ -14,9 +14,12 @@ export type AttachedMedia = { url: string; }; +export type MediaSourceValue = 'featured-image' | 'sig' | 'media-library' | 'upload-video' | 'none'; + export type JetpackSocialOptions = { attached_media?: Array< AttachedMedia >; image_generator_settings?: SIGSettings; + media_source?: MediaSourceValue; }; export type JetpackSocialPostMeta = { @@ -32,14 +35,17 @@ export type UsePostMeta = { isPostAlreadyShared: boolean; isPublicizeEnabled: boolean; jetpackSocialOptions: JetpackSocialOptions; + mediaSource: MediaSourceValue | undefined; shareMessage: string; togglePublicizeFeature: VoidFunction; updateMeta: < K extends keyof JetpackSocialPostMeta >( metaKey: K, metaValue: JetpackSocialPostMeta[ K ] ) => void; - updateJetpackSocialOptions: < K extends keyof JetpackSocialOptions >( - key: K, - value: JetpackSocialOptions[ K ] - ) => void; + updateJetpackSocialOptions: { + // Single key-value update + < K extends keyof JetpackSocialOptions >( key: K, value: JetpackSocialOptions[ K ] ): void; + // Batch update with object + ( updates: Partial< JetpackSocialOptions > ): void; + }; }; diff --git a/projects/packages/publicize/changelog/add-social-new-media-selector-ui b/projects/packages/publicize/changelog/add-social-new-media-selector-ui new file mode 100644 index 0000000000000..cf502cf278d39 --- /dev/null +++ b/projects/packages/publicize/changelog/add-social-new-media-selector-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added new media selector UI. diff --git a/projects/packages/publicize/src/class-publicize-base.php b/projects/packages/publicize/src/class-publicize-base.php index c182a41995534..575ebb1ce6381 100644 --- a/projects/packages/publicize/src/class-publicize-base.php +++ b/projects/packages/publicize/src/class-publicize-base.php @@ -1202,6 +1202,10 @@ public function register_post_meta() { ), ), ), + 'media_source' => array( + 'type' => 'string', + 'enum' => array( 'featured-image', 'sig', 'media-library', 'upload-video', 'none' ), + ), ), ), ), diff --git a/projects/plugins/jetpack/changelog/add-social-new-media-selector-ui b/projects/plugins/jetpack/changelog/add-social-new-media-selector-ui new file mode 100644 index 0000000000000..34fb0082fd3fc --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-social-new-media-selector-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Add the new media selection UI for Social. diff --git a/projects/plugins/social/changelog/add-social-new-media-selector-ui b/projects/plugins/social/changelog/add-social-new-media-selector-ui new file mode 100644 index 0000000000000..bc731a24e862f --- /dev/null +++ b/projects/plugins/social/changelog/add-social-new-media-selector-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add the new media selection UI for Social.