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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/* eslint-disable import/order */
import { clearMockedScriptData, mockScriptData } from '../../../utils/test-utils';

mockScriptData();

jest.mock( '@automattic/jetpack-script-data', () => {
const actual = jest.requireActual( '@automattic/jetpack-script-data' );
return {
...actual,
siteHasFeature: jest.fn(),
};
} );

jest.mock( '@wordpress/data', () => {
const actual = jest.requireActual( '@wordpress/data' );
const mocks = {
useSelect: jest.fn(),
};
return new Proxy( actual, {
get( target, property ) {
return mocks[ property as keyof typeof mocks ] ?? target[ property as keyof typeof target ];
},
} );
} );

jest.mock( '../../../hooks/use-manual-share-message', () => ( {
useManualShareMessage: jest.fn(),
} ) );

jest.mock( '../../../hooks/use-post-meta', () => ( {
usePostMeta: jest.fn(),
} ) );

import { renderHook } from '@testing-library/react';
import { useSelect } from '@wordpress/data';
import { useShareButtonText } from '../useShareButtonText';
import { useManualShareMessage } from '../../../hooks/use-manual-share-message';
import { usePostMeta } from '../../../hooks/use-post-meta';

const mockSiteHasFeature = jest.requireMock( '@automattic/jetpack-script-data' )
.siteHasFeature as jest.Mock;
const mockUseSelect = useSelect as jest.Mock;
const mockUseManualShareMessage = useManualShareMessage as jest.Mock;
const mockUsePostMeta = usePostMeta as jest.Mock;

// Matches a single-brace token like {title} but not a double-brace {{text}} slot.
const TOKEN_RE = /\{(?!\{)(title|excerpt|url|author)\}/;

const LINK = 'https://example.com/post';

const X_TEMPLATE = 'https://x.com/intent/tweet?text={{text}}&url={{url}}';
const FACEBOOK_TEMPLATE = 'https://www.facebook.com/sharer/sharer.php?u={{url}}';
const CLIPBOARD_TEMPLATE = '{{text}}\n{{url}}';

/**
* Set up the editor useSelect return: post link + token-free title fallback.
*
* @param titleFallback - The SEO/post title fallback.
*/
function mockEditor( titleFallback = 'My Post Title' ) {
mockUseSelect.mockReturnValue( { link: LINK, titleFallback } );
}

describe( 'useShareButtonText', () => {
beforeEach( () => {
jest.clearAllMocks();
mockUsePostMeta.mockReturnValue( { shareMessage: '' } );
mockUseManualShareMessage.mockReturnValue( { message: null, isLoading: false } );
} );

afterAll( () => {
clearMockedScriptData();
} );

it( 'renders the WPCOM-rendered message and leaks no template tokens (regression)', () => {
mockSiteHasFeature.mockReturnValue( true );
mockEditor();
// The raw template is still in post meta; it must never reach the output.
mockUsePostMeta.mockReturnValue( { shareMessage: '{title} {excerpt} {url} {author}' } );
mockUseManualShareMessage.mockReturnValue( { message: 'Hello world', isLoading: false } );

const { result } = renderHook( () => useShareButtonText() );
const output = result.current( CLIPBOARD_TEMPLATE, false );

expect( output ).toContain( 'Hello world' );
expect( output ).not.toMatch( TOKEN_RE );
} );

it( 'passes the plain shareMessage through unchanged when templates are off', () => {
mockSiteHasFeature.mockReturnValue( false );
mockEditor();
mockUsePostMeta.mockReturnValue( { shareMessage: 'Check out my post' } );

const { result } = renderHook( () => useShareButtonText() );
const output = result.current( X_TEMPLATE );

// Byte-identical to the legacy output: message + separately-carried url.
expect( output ).toBe(
`https://x.com/intent/tweet?text=${ encodeURIComponent(
'Check out my post'
) }&url=${ encodeURIComponent( LINK ) }`
);
} );

it( 'falls back to the token-free title while the rendered message loads', () => {
mockSiteHasFeature.mockReturnValue( true );
mockEditor( 'My Post Title' );
mockUseManualShareMessage.mockReturnValue( { message: null, isLoading: true } );
// A custom template is set, but it must NOT be used while templates are on.
mockUsePostMeta.mockReturnValue( { shareMessage: 'Sharing {title} {url}' } );

const { result } = renderHook( () => useShareButtonText() );
const output = result.current( CLIPBOARD_TEMPLATE, false );

expect( output ).toContain( 'My Post Title' );
expect( output ).not.toMatch( TOKEN_RE );
} );

it( 'de-duplicates the URL when the rendered message already contains it', () => {
mockSiteHasFeature.mockReturnValue( true );
mockEditor();
const rendered = `Great post\n\n${ LINK }`;
mockUseManualShareMessage.mockReturnValue( { message: rendered, isLoading: false } );

const { result } = renderHook( () => useShareButtonText() );
const output = result.current( X_TEMPLATE );
const decoded = decodeURIComponent( output );

// The link should appear exactly once (inside the text, not also in &url=).
const occurrences = decoded.split( LINK ).length - 1;
expect( occurrences ).toBe( 1 );
// The trailing url slot is emptied out.
expect( output ).toMatch( /&url=$/ );
} );

it( 'keeps the URL for a url-only template (Facebook) even with templates on', () => {
mockSiteHasFeature.mockReturnValue( true );
mockEditor();
const rendered = `Great post\n\n${ LINK }`;
mockUseManualShareMessage.mockReturnValue( { message: rendered, isLoading: false } );

const { result } = renderHook( () => useShareButtonText() );
const output = result.current( FACEBOOK_TEMPLATE );

expect( output ).toBe(
`https://www.facebook.com/sharer/sharer.php?u=${ encodeURIComponent( LINK ) }`
);
} );
} );
Original file line number Diff line number Diff line change
@@ -1,46 +1,69 @@
import { siteHasFeature } from '@automattic/jetpack-script-data';
import { useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { useCallback } from '@wordpress/element';
import { useManualShareMessage } from '../../hooks/use-manual-share-message';
import { usePostMeta } from '../../hooks/use-post-meta';
import { features } from '../../utils';

/**
* Prepares the text to share.
*
* @return {(textWithPlaceholders: string, isUrl: boolean) => string} A function that accepts the text with placeholders and returns the text with the placeholders replaced.
*/
export function useShareButtonText() {
const templatesEnabled = siteHasFeature( features.MESSAGE_TEMPLATES );
const { shareMessage } = usePostMeta();
const { message, link } = useSelect(
select => {
const { getEditedPostAttribute } = select( editorStore );

return {
link: getEditedPostAttribute( 'link' ),
message:
shareMessage ||
getEditedPostAttribute( 'meta' )?.jetpack_seo_html_title ||
getEditedPostAttribute( 'title' ),
};
},
[ shareMessage ]
);
const manual = useManualShareMessage();

const { link, titleFallback } = useSelect( select => {
const { getEditedPostAttribute } = select( editorStore );

return {
link: getEditedPostAttribute( 'link' ),
// Token-free fallback shared by both branches: SEO title, else post title.
titleFallback:
getEditedPostAttribute( 'meta' )?.jetpack_seo_html_title ||
getEditedPostAttribute( 'title' ),
};
}, [] );

// With templates on, use the WPCOM-rendered message when available, else fall
// back to the token-free title while it loads or if it's unavailable — never
// the raw `shareMessage` template, which would leak `{title}`/`{url}`/etc.
// tokens into the compose window. With templates off, behaviour is unchanged.
const message = templatesEnabled
? manual.message ?? titleFallback
: shareMessage || titleFallback;

return useCallback(
( textWithPlaceholders: string, isUrl = true ) => {
let text = message;
let url = link;
// If the URL placeholder is missing, add the URL to the text.
if ( ! textWithPlaceholders.includes( '{{url}}' ) ) {

const hasTextSlot = textWithPlaceholders.includes( '{{text}}' );
const hasUrlSlot = textWithPlaceholders.includes( '{{url}}' );

if ( hasTextSlot && link && text.includes( link ) ) {
// The message is placed into the text and already carries the post URL
// (from the `{url}` token), so leave the url slot empty to avoid
// duplicating the link.
url = '';
} else if ( ! hasUrlSlot ) {
// No url slot to fill (e.g. WhatsApp/clipboard-text) — fold the URL
// into the text instead.
text = text + '\n\n' + url;
url = '';
}
// Otherwise keep the url slot (e.g. Facebook, which has no text slot, or
// the title fallback where the text doesn't contain the link).

if ( isUrl ) {
text = encodeURIComponent( text );
url = encodeURIComponent( url );
}

return textWithPlaceholders.replace( '{{text}}', text ).replace( '{{url}}', url );
return textWithPlaceholders.replaceAll( '{{text}}', text ).replaceAll( '{{url}}', url );
},
[ link, message ]
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { siteHasFeature } from '@automattic/jetpack-script-data';
import { useRegistry, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { useEffect, useMemo } from '@wordpress/element';
import { store as socialStore } from '../../social-store';
import { features } from '../../utils';
import { MANUAL_SHARE_SENTINEL, type RenderItem } from '../../utils/render-messages';
import { useDebouncedRenderInputs, usePostIntent } from '../use-render-message-items';
import useSocialMediaMessage from '../use-social-media-message';

export type ManualShareMessage = {
message: string | null;
isLoading: boolean;
};

/**
* Resolve the WPCOM-rendered global message for the manual-sharing buttons.
*
* The manual-share buttons are the one sharing path that historically dropped the
* raw single-brace template (`{title}` …) straight into the compose window instead
* of the server-rendered text. This hook renders the global template through the
* same `wpcom/v2/publicize/render-messages` endpoint the preview panel and
* auto-share use, so the buttons can show finished text.
*
* Because manual sharing can exist with zero connections, we render a dedicated
* single item under the {@link MANUAL_SHARE_SENTINEL} `connection_id` rather than
* piggy-backing on a connection's render item. Only the post intent and the
* debouncer are shared with the preview path — none of its connection/media/SIG
* plumbing is mounted here.
*
* @return `{ message, isLoading }`. `message` is the rendered text when available,
* otherwise `null` (never the raw template) so the consumer can fall back to a
* token-free post title. When the templates feature is off, or there is no
* template, returns `{ message: null, isLoading: false }`.
*/
export function useManualShareMessage(): ManualShareMessage {
const templatesEnabled = siteHasFeature( features.MESSAGE_TEMPLATES );
const registry = useRegistry();

const { message: globalMessage } = useSocialMediaMessage();

const { siteMessageTemplate, postId } = useSelect(
select => ( {
siteMessageTemplate: templatesEnabled
? select( socialStore ).getSocialSettings().messageTemplate ?? ''
: '',
postId: select( editorStore ).getCurrentPostId() as number | undefined,
} ),
[ templatesEnabled ]
);

// Prefer the per-post global message (`_wpas_mess`), else the saved site
// template — matching what the preview panel renders for the global message.
const template = ( globalMessage || siteMessageTemplate || '' ).trim();
const rawPostIntent = usePostIntent();

const rawInputs = useMemo(
() => ( {
items: [
{
connection_id: MANUAL_SHARE_SENTINEL,
message: template,
// Manual sharing is a link share, not a media/social post.
is_social_post: false,
},
] as RenderItem[],
postIntent: rawPostIntent,
} ),
[ template, rawPostIntent ]
);

// Same debounce the preview path uses, so keystrokes in the message field or
// the post title don't fire a render request per character.
const { items, postIntent } = useDebouncedRenderInputs( rawInputs );
const debouncedTemplate = items[ 0 ]?.message ?? '';

const canRender = templatesEnabled && Boolean( postId ) && debouncedTemplate.length > 0;

// The sidebar / post-publish panels don't mount `useDriveRenderedMessagesFetch`,
// so drive the fetch ourselves. Errors are swallowed to preserve the fallback.
useEffect( () => {
if ( ! canRender || ! postId ) {
return;
}

void registry
.resolveSelect( socialStore )
.getRenderedMessages( postId, items, postIntent )
.catch( () => {} );
}, [ canRender, postId, items, postIntent, registry ] );

const { rendered, isLoadingRendered } = useSelect(
select => {
if ( ! canRender || ! postId ) {
return { rendered: null, isLoadingRendered: false };
}
// Cache-only read; the effect above owns fetching.
const social = select( socialStore );
const batch = social.getCachedRenderedMessages( postId, items, postIntent );

return {
rendered: batch?.[ MANUAL_SHARE_SENTINEL ]?.rendered_message ?? null,
isLoadingRendered: social.isLoadingRenderedMessages( postId, items, postIntent ),
};
},
[ canRender, postId, items, postIntent ]
);

// True while the user has typed but the debounce hasn't committed the new
// template yet — the store can't see the edit until then. Mirrors the
// `isDebouncingRenderedMessage` flag in `useConnectionPreviewData`.
const isDebouncing = templatesEnabled && template.length > 0 && debouncedTemplate !== template;

return useMemo( () => {
if ( ! templatesEnabled || template.length === 0 ) {
return { message: null, isLoading: false };
}

return {
message: typeof rendered === 'string' ? rendered : null,
isLoading: isDebouncing || isLoadingRendered,
};
}, [ templatesEnabled, template, rendered, isDebouncing, isLoadingRendered ] );
}
Loading
Loading