diff --git a/.github/changelog/2971-from-description b/.github/changelog/2971-from-description new file mode 100644 index 000000000..50418a10e --- /dev/null +++ b/.github/changelog/2971-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a pre-publish suggestion that recommends a post format for better compatibility with media-focused Fediverse platforms. diff --git a/build/pre-publish-panel/block.json b/build/pre-publish-panel/block.json new file mode 100644 index 000000000..dd1d305d9 --- /dev/null +++ b/build/pre-publish-panel/block.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "activitypub/pre-publish-panel", + "title": "ActivityPub Post Format Suggestions", + "category": "widgets", + "description": "Suggests optimal post formats for ActivityPub federation before publishing.", + "icon": "layout", + "textdomain": "activitypub", + "editorScript": "file:./plugin.js" +} \ No newline at end of file diff --git a/build/pre-publish-panel/plugin.asset.php b/build/pre-publish-panel/plugin.asset.php new file mode 100644 index 000000000..354b89bb2 --- /dev/null +++ b/build/pre-publish-panel/plugin.asset.php @@ -0,0 +1 @@ + array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-dom', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => '465a213971cc6d890a65'); diff --git a/build/pre-publish-panel/plugin.js b/build/pre-publish-panel/plugin.js new file mode 100644 index 000000000..be56f9c50 --- /dev/null +++ b/build/pre-publish-panel/plugin.js @@ -0,0 +1,2 @@ +(()=>{"use strict";const t=window.wp.editor,e=window.wp.blockEditor,o=window.wp.plugins,i=window.wp.components,n=window.wp.data,a=window.wp.element,s=window.wp.i18n,r=window.wp.dom,l=window._activityPubOptions?.noteLength||500,u=["youtube","vimeo","dailymotion","tiktok","videopress"],d=["spotify","soundcloud","mixcloud"],c=["core/paragraph","core/heading","core/list-item","core/preformatted","core/verse","core/pullquote"],m=["core/gallery","jetpack/tiled-gallery","jetpack/slideshow"],p=(t,e)=>"core/pullquote"===t?e?.value||"":e?.content||"",g=t=>{const e={imageCount:0,galleryCount:0,videoCount:0,audioCount:0,textLength:0,textBlockCount:0};if(!t||!t.length)return e;for(const o of t){const{name:t,attributes:i,innerBlocks:n}=o;if("core/image"===t)e.imageCount++;else if(m.includes(t))e.galleryCount++;else if("core/video"===t)e.videoCount++;else if("core/audio"===t)e.audioCount++;else if("core/embed"===t){const t=(i?.providerNameSlug||"").toLowerCase();u.includes(t)?e.videoCount++:d.includes(t)&&e.audioCount++}if(c.includes(t)){const o=(0,r.__unstableStripHTML)(p(t,i));e.textLength+=o.length,e.textBlockCount++}if(n&&n.length){const t=g(n);e.imageCount+=t.imageCount,e.galleryCount+=t.galleryCount,e.videoCount+=t.videoCount,e.audioCount+=t.audioCount,e.textLength+=t.textLength,e.textBlockCount+=t.textBlockCount}}return e},h=window.ReactJSXRuntime;(0,o.registerPlugin)("activitypub-pre-publish",{render:()=>{const{blocks:o,postFormat:r}=(0,n.useSelect)(o=>({blocks:o(e.store).getBlocks(),postFormat:o(t.store).getEditedPostAttribute("format")}),[]),{editPost:u}=(0,n.useDispatch)(t.store),d=(0,a.useMemo)(()=>((t,e)=>{if(e&&"standard"!==e)return null;const o=g(t),i=o.imageCount>0||o.galleryCount>0||o.videoCount>0||o.audioCount>0;return(o.galleryCount>0||o.imageCount>1)&&o.textLength0&&o.textLength0&&o.textLength0&&o.textLength<280&&o.textBlockCount<=3?{format:"status",message:(0,s.__)("This is a short post with no media. Setting the format to Status will share it as a Note, which is the standard format on platforms like Mastodon.","activitypub")}:null})(o,r),[o,r]);return"wordpress-post-format"!==window._activityPubOptions?.objectType?null:d?(0,h.jsxs)(t.PluginPrePublishPanel,{title:(0,s.__)("Fediverse ⁂","activitypub"),initialOpen:!0,children:[(0,h.jsx)("p",{children:d.message}),(0,h.jsx)(i.Button,{variant:"secondary",onClick:()=>u({format:d.format}),children:(0,s.sprintf)(/* translators: %s: The suggested post format name (e.g., "Image", "Gallery", "Video"). */ /* translators: %s: The suggested post format name (e.g., "Image", "Gallery", "Video"). */ +(0,s.__)("Set format to %s","activitypub"),d.format.charAt(0).toUpperCase()+d.format.slice(1))})]}):null}})})(); \ No newline at end of file diff --git a/includes/class-blocks.php b/includes/class-blocks.php index 4bf0aa8b2..364c36ccb 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -50,6 +50,8 @@ public static function enqueue_editor_assets() { ), 'showAvatars' => (bool) \get_option( 'show_avatars' ), 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ), + 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ), + 'noteLength' => ACTIVITYPUB_NOTE_LENGTH, ); wp_localize_script( 'wp-editor', '_activityPubOptions', $data ); @@ -63,6 +65,10 @@ public static function enqueue_editor_assets() { $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php'; $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE ); wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true ); + + $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/pre-publish-panel/plugin.asset.php'; + $plugin_url = plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE ); + wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true ); } /** diff --git a/includes/constants.php b/includes/constants.php index 7a40b895b..8c192d511 100644 --- a/includes/constants.php +++ b/includes/constants.php @@ -9,7 +9,7 @@ defined( 'ACTIVITYPUB_REST_NAMESPACE' ) || define( 'ACTIVITYPUB_REST_NAMESPACE', 'activitypub/1.0' ); defined( 'ACTIVITYPUB_EXCERPT_LENGTH' ) || define( 'ACTIVITYPUB_EXCERPT_LENGTH', 400 ); -defined( 'ACTIVITYPUB_NOTE_LENGTH' ) || define( 'ACTIVITYPUB_NOTE_LENGTH', 400 ); +defined( 'ACTIVITYPUB_NOTE_LENGTH' ) || define( 'ACTIVITYPUB_NOTE_LENGTH', 500 ); defined( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS' ) || define( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS', 4 ); defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=

)|(?<=
)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' ); defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9\._-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' ); diff --git a/src/pre-publish-panel/__tests__/plugin.test.js b/src/pre-publish-panel/__tests__/plugin.test.js new file mode 100644 index 000000000..13802a874 --- /dev/null +++ b/src/pre-publish-panel/__tests__/plugin.test.js @@ -0,0 +1,399 @@ +/** + * @jest-environment jsdom + */ + +// Set up the global options before importing utils, so NOTE_LENGTH picks up the value. +window._activityPubOptions = { noteLength: 500 }; + +import { analyzeBlocks, getSuggestedPostFormat } from '../utils'; + +describe( 'analyzeBlocks', () => { + test( 'returns all zeros for empty blocks', () => { + expect( analyzeBlocks( [] ) ).toEqual( { + imageCount: 0, + galleryCount: 0, + videoCount: 0, + audioCount: 0, + textLength: 0, + textBlockCount: 0, + } ); + } ); + + test( 'returns all zeros for null/undefined', () => { + expect( analyzeBlocks( null ) ).toEqual( { + imageCount: 0, + galleryCount: 0, + videoCount: 0, + audioCount: 0, + textLength: 0, + textBlockCount: 0, + } ); + } ); + + test( 'counts text from a single paragraph', () => { + const blocks = [ + { + name: 'core/paragraph', + attributes: { content: 'Hello world' }, + innerBlocks: [], + }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.textLength ).toBe( 11 ); + expect( result.textBlockCount ).toBe( 1 ); + } ); + + test( 'strips HTML from text content', () => { + const blocks = [ + { + name: 'core/paragraph', + attributes: { + content: 'Hello world', + }, + innerBlocks: [], + }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.textLength ).toBe( 11 ); // "Hello world" + } ); + + test( 'counts image blocks', () => { + const blocks = [ + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { name: 'core/image', attributes: {}, innerBlocks: [] }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.imageCount ).toBe( 2 ); + } ); + + test( 'counts gallery blocks', () => { + const blocks = [ { name: 'core/gallery', attributes: {}, innerBlocks: [] } ]; + const result = analyzeBlocks( blocks ); + expect( result.galleryCount ).toBe( 1 ); + } ); + + test( 'counts jetpack gallery blocks', () => { + const blocks = [ + { + name: 'jetpack/tiled-gallery', + attributes: {}, + innerBlocks: [], + }, + { name: 'jetpack/slideshow', attributes: {}, innerBlocks: [] }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.galleryCount ).toBe( 2 ); + } ); + + test( 'counts video blocks', () => { + const blocks = [ { name: 'core/video', attributes: {}, innerBlocks: [] } ]; + const result = analyzeBlocks( blocks ); + expect( result.videoCount ).toBe( 1 ); + } ); + + test( 'counts audio blocks', () => { + const blocks = [ { name: 'core/audio', attributes: {}, innerBlocks: [] } ]; + const result = analyzeBlocks( blocks ); + expect( result.audioCount ).toBe( 1 ); + } ); + + test( 'detects video embed providers', () => { + const blocks = [ + { + name: 'core/embed', + attributes: { providerNameSlug: 'youtube' }, + innerBlocks: [], + }, + { + name: 'core/embed', + attributes: { providerNameSlug: 'vimeo' }, + innerBlocks: [], + }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.videoCount ).toBe( 2 ); + } ); + + test( 'detects audio embed providers', () => { + const blocks = [ + { + name: 'core/embed', + attributes: { providerNameSlug: 'spotify' }, + innerBlocks: [], + }, + { + name: 'core/embed', + attributes: { providerNameSlug: 'soundcloud' }, + innerBlocks: [], + }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.audioCount ).toBe( 2 ); + } ); + + test( 'counts nested inner blocks recursively', () => { + const blocks = [ + { + name: 'core/group', + attributes: {}, + innerBlocks: [ + { + name: 'core/image', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'Nested text' }, + innerBlocks: [], + }, + ], + }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.imageCount ).toBe( 1 ); + expect( result.textLength ).toBe( 11 ); + expect( result.textBlockCount ).toBe( 1 ); + } ); + + test( 'counts multiple text block types', () => { + const blocks = [ + { + name: 'core/heading', + attributes: { content: 'Title' }, + innerBlocks: [], + }, + { + name: 'core/preformatted', + attributes: { content: 'Code' }, + innerBlocks: [], + }, + { + name: 'core/verse', + attributes: { content: 'Poem' }, + innerBlocks: [], + }, + { + name: 'core/pullquote', + attributes: { value: 'Quote' }, + innerBlocks: [], + }, + { + name: 'core/list-item', + attributes: { content: 'Item' }, + innerBlocks: [], + }, + ]; + const result = analyzeBlocks( blocks ); + expect( result.textBlockCount ).toBe( 5 ); + expect( result.textLength ).toBe( 22 ); // Title(5) + Code(4) + Poem(4) + Quote(5) + Item(4) + } ); +} ); + +describe( 'getSuggestedPostFormat', () => { + test( 'returns null when format is already set', () => { + const blocks = [ { name: 'core/image', attributes: {}, innerBlocks: [] } ]; + expect( getSuggestedPostFormat( blocks, 'image' ) ).toBeNull(); + expect( getSuggestedPostFormat( blocks, 'gallery' ) ).toBeNull(); + expect( getSuggestedPostFormat( blocks, 'aside' ) ).toBeNull(); + } ); + + test( 'suggests gallery for gallery blocks with short text', () => { + const blocks = [ + { name: 'core/gallery', attributes: {}, innerBlocks: [] }, + { + name: 'core/paragraph', + attributes: { content: 'Caption' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, 'standard' ); + expect( result.format ).toBe( 'gallery' ); + } ); + + test( 'suggests gallery for multiple images with short text', () => { + const blocks = [ + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { + name: 'core/paragraph', + attributes: { content: 'Photos' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'gallery' ); + } ); + + test( 'suggests image for single image with short text', () => { + const blocks = [ + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { + name: 'core/paragraph', + attributes: { content: 'My photo' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'image' ); + } ); + + test( 'suggests video for video with short text', () => { + const blocks = [ + { name: 'core/video', attributes: {}, innerBlocks: [] }, + { + name: 'core/paragraph', + attributes: { content: 'Watch this' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'video' ); + } ); + + test( 'suggests video for YouTube embed', () => { + const blocks = [ + { + name: 'core/embed', + attributes: { providerNameSlug: 'youtube' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'video' ); + } ); + + test( 'suggests audio for audio with short text', () => { + const blocks = [ + { name: 'core/audio', attributes: {}, innerBlocks: [] }, + { + name: 'core/paragraph', + attributes: { content: 'Listen' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'audio' ); + } ); + + test( 'suggests audio for Spotify embed', () => { + const blocks = [ + { + name: 'core/embed', + attributes: { providerNameSlug: 'spotify' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'audio' ); + } ); + + test( 'suggests status for very short text-only posts', () => { + const blocks = [ + { + name: 'core/paragraph', + attributes: { content: 'Just a quick thought.' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'status' ); + } ); + + test( 'does not suggest status when text is too long', () => { + const blocks = [ + { + name: 'core/paragraph', + attributes: { content: 'A'.repeat( 300 ) }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result ).toBeNull(); + } ); + + test( 'does not suggest status when there are too many text blocks', () => { + const blocks = [ + { + name: 'core/paragraph', + attributes: { content: 'One' }, + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'Two' }, + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'Three' }, + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'Four' }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result ).toBeNull(); + } ); + + test( 'returns null for long text articles', () => { + const blocks = [ + { + name: 'core/image', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'A'.repeat( 600 ) }, + innerBlocks: [], + }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result ).toBeNull(); + } ); + + test( 'returns null when no blocks present', () => { + expect( getSuggestedPostFormat( [], '' ) ).toBeNull(); + } ); + + test( 'works with falsy currentFormat values', () => { + const blocks = [ { name: 'core/image', attributes: {}, innerBlocks: [] } ]; + // All falsy values should allow suggestion. + expect( getSuggestedPostFormat( blocks, '' ) ).not.toBeNull(); + expect( getSuggestedPostFormat( blocks, null ) ).not.toBeNull(); + expect( getSuggestedPostFormat( blocks, undefined ) ).not.toBeNull(); + expect( getSuggestedPostFormat( blocks, 'standard' ) ).not.toBeNull(); + } ); + + test( 'suggests video over image for mixed media', () => { + const blocks = [ + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { name: 'core/video', attributes: {}, innerBlocks: [] }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'video' ); + } ); + + test( 'suggests audio over image for mixed media', () => { + const blocks = [ + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { name: 'core/audio', attributes: {}, innerBlocks: [] }, + ]; + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'audio' ); + } ); + + test( 'gallery has higher priority than image', () => { + const blocks = [ + { name: 'core/image', attributes: {}, innerBlocks: [] }, + { name: 'core/image', attributes: {}, innerBlocks: [] }, + ]; + // Multiple images should suggest gallery, not image. + const result = getSuggestedPostFormat( blocks, '' ); + expect( result.format ).toBe( 'gallery' ); + } ); +} ); diff --git a/src/pre-publish-panel/block.json b/src/pre-publish-panel/block.json new file mode 100644 index 000000000..a98e6c270 --- /dev/null +++ b/src/pre-publish-panel/block.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "activitypub/pre-publish-panel", + "title": "ActivityPub Post Format Suggestions", + "category": "widgets", + "description": "Suggests optimal post formats for ActivityPub federation before publishing.", + "icon": "layout", + "textdomain": "activitypub", + "editorScript": "file:./plugin.js" +} diff --git a/src/pre-publish-panel/plugin.js b/src/pre-publish-panel/plugin.js new file mode 100644 index 000000000..06d04b16f --- /dev/null +++ b/src/pre-publish-panel/plugin.js @@ -0,0 +1,53 @@ +import { PluginPrePublishPanel, store as editorStore } from '@wordpress/editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { registerPlugin } from '@wordpress/plugins'; +import { Button } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { getSuggestedPostFormat } from './utils'; + +/** + * Pre-publish panel that suggests a post format for better federation. + * + * Only renders when the object type setting is 'wordpress-post-format' and + * the post content suggests a better format than the default Article. + * + * @return {React.JSX.Element|null} The pre-publish panel or null. + */ +const PrePublishPanel = () => { + const { blocks, postFormat } = useSelect( ( selectFn ) => { + return { + blocks: selectFn( blockEditorStore ).getBlocks(), + postFormat: selectFn( editorStore ).getEditedPostAttribute( 'format' ), + }; + }, [] ); + + const { editPost } = useDispatch( editorStore ); + + const suggestion = useMemo( () => getSuggestedPostFormat( blocks, postFormat ), [ blocks, postFormat ] ); + + // Only show when object type is set to post format mapping. + if ( window._activityPubOptions?.objectType !== 'wordpress-post-format' ) { + return null; + } + + if ( ! suggestion ) { + return null; + } + + return ( + +

{ suggestion.message }

+ + + ); +}; + +registerPlugin( 'activitypub-pre-publish', { render: PrePublishPanel } ); diff --git a/src/pre-publish-panel/utils.js b/src/pre-publish-panel/utils.js new file mode 100644 index 000000000..035dd2be6 --- /dev/null +++ b/src/pre-publish-panel/utils.js @@ -0,0 +1,210 @@ +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { __ } from '@wordpress/i18n'; + +/** + * The maximum note length from the server-side ACTIVITYPUB_NOTE_LENGTH constant. + * Fallback matches ACTIVITYPUB_NOTE_LENGTH default; keep in sync with includes/constants.php. + * + * @type {number} + */ +const NOTE_LENGTH = window._activityPubOptions?.noteLength || 500; + +/** + * Video embed providers for detecting video content in embed blocks. + * + * @type {string[]} + */ +const VIDEO_PROVIDERS = [ 'youtube', 'vimeo', 'dailymotion', 'tiktok', 'videopress' ]; + +/** + * Audio embed providers for detecting audio content in embed blocks. + * + * @type {string[]} + */ +const AUDIO_PROVIDERS = [ 'spotify', 'soundcloud', 'mixcloud' ]; + +/** + * Block names that contain text content. + * + * @type {string[]} + */ +const TEXT_BLOCK_NAMES = [ + 'core/paragraph', + 'core/heading', + 'core/list-item', + 'core/preformatted', + 'core/verse', + 'core/pullquote', +]; + +/** + * Block names that represent gallery content. + * + * @type {string[]} + */ +const GALLERY_BLOCK_NAMES = [ 'core/gallery', 'jetpack/tiled-gallery', 'jetpack/slideshow' ]; + +/** + * Returns the text content attribute for a given block name. + * + * Most text blocks store content in `attributes.content`, but some + * blocks use different attribute keys (e.g. `core/pullquote` uses `value`). + * + * @param {string} name The block name. + * @param {Object} attributes The block attributes. + * + * @return {string} The text content. + */ +const getBlockTextContent = ( name, attributes ) => { + if ( name === 'core/pullquote' ) { + return attributes?.value || ''; + } + return attributes?.content || ''; +}; + +/** + * Recursively analyzes blocks and returns content statistics. + * + * Walks all blocks (including inner blocks) and counts media elements + * and text content to help determine the best post format. + * + * @param {Array} blocks The blocks to analyze. + * + * @return {Object} Content statistics with imageCount, galleryCount, videoCount, audioCount, textLength, textBlockCount. + */ +export const analyzeBlocks = ( blocks ) => { + const result = { + imageCount: 0, + galleryCount: 0, + videoCount: 0, + audioCount: 0, + textLength: 0, + textBlockCount: 0, + }; + + if ( ! blocks || ! blocks.length ) { + return result; + } + + for ( const block of blocks ) { + const { name, attributes, innerBlocks } = block; + + if ( name === 'core/image' ) { + result.imageCount++; + } else if ( GALLERY_BLOCK_NAMES.includes( name ) ) { + result.galleryCount++; + } else if ( name === 'core/video' ) { + result.videoCount++; + } else if ( name === 'core/audio' ) { + result.audioCount++; + } else if ( name === 'core/embed' ) { + const provider = ( attributes?.providerNameSlug || '' ).toLowerCase(); + if ( VIDEO_PROVIDERS.includes( provider ) ) { + result.videoCount++; + } else if ( AUDIO_PROVIDERS.includes( provider ) ) { + result.audioCount++; + } + } + + if ( TEXT_BLOCK_NAMES.includes( name ) ) { + const text = stripHTML( getBlockTextContent( name, attributes ) ); + result.textLength += text.length; + result.textBlockCount++; + } + + if ( innerBlocks && innerBlocks.length ) { + const inner = analyzeBlocks( innerBlocks ); + result.imageCount += inner.imageCount; + result.galleryCount += inner.galleryCount; + result.videoCount += inner.videoCount; + result.audioCount += inner.audioCount; + result.textLength += inner.textLength; + result.textBlockCount += inner.textBlockCount; + } + } + + return result; +}; + +/** + * Suggests a post format based on block content analysis. + * + * Returns a suggestion only when the current format is the default (standard). + * If the user has explicitly chosen a format, no suggestion is made. + * + * @param {Array} blocks The blocks to analyze. + * @param {string} currentFormat The current post format. + * + * @return {Object|null} Suggestion object with `format` and `message`, or null. + */ +export const getSuggestedPostFormat = ( blocks, currentFormat ) => { + // Don't suggest if user explicitly set a format. + if ( currentFormat && currentFormat !== 'standard' ) { + return null; + } + + const stats = analyzeBlocks( blocks ); + const hasMedia = stats.imageCount > 0 || stats.galleryCount > 0 || stats.videoCount > 0 || stats.audioCount > 0; + + // Gallery: gallery blocks or multiple images with short text. + if ( ( stats.galleryCount > 0 || stats.imageCount > 1 ) && stats.textLength < NOTE_LENGTH ) { + return { + format: 'gallery', + message: __( + 'This post contains multiple images. Setting the format to Gallery will share it as a media post, making it visible on platforms like Pixelfed.', + 'activitypub' + ), + }; + } + + // Video: video with short text. + if ( stats.videoCount > 0 && stats.textLength < NOTE_LENGTH ) { + return { + format: 'video', + message: __( + 'This post contains a video. Setting the format to Video will share it as a media post, improving compatibility with video-focused platforms.', + 'activitypub' + ), + }; + } + + // Audio: audio with short text. + if ( stats.audioCount > 0 && stats.textLength < NOTE_LENGTH ) { + return { + format: 'audio', + message: __( + 'This post contains audio content. Setting the format to Audio will share it as a media post, improving compatibility with audio-focused platforms.', + 'activitypub' + ), + }; + } + + // Image: single image with short text (after video/audio to avoid wrong suggestion for mixed media). + if ( + stats.imageCount === 1 && + stats.videoCount === 0 && + stats.audioCount === 0 && + stats.textLength < NOTE_LENGTH + ) { + return { + format: 'image', + message: __( + 'This post contains an image. Setting the format to Image will share it as a media post, making it visible on platforms like Pixelfed.', + 'activitypub' + ), + }; + } + + // Status: very short text-only. + if ( ! hasMedia && stats.textLength > 0 && stats.textLength < 280 && stats.textBlockCount <= 3 ) { + return { + format: 'status', + message: __( + 'This is a short post with no media. Setting the format to Status will share it as a Note, which is the standard format on platforms like Mastodon.', + 'activitypub' + ), + }; + } + + return null; +};