diff --git a/.github/changelog/2439-from-description b/.github/changelog/2439-from-description new file mode 100644 index 000000000..67236440c --- /dev/null +++ b/.github/changelog/2439-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added a new Fediverse Extra Fields block to display ActivityPub extra fields, featuring compact, stacked, and card layouts with flexible user selection options. diff --git a/assets/css/activitypub-welcome.css b/assets/css/activitypub-welcome.css index 182d8e19d..3faf3ffbf 100644 --- a/assets/css/activitypub-welcome.css +++ b/assets/css/activitypub-welcome.css @@ -223,11 +223,11 @@ padding: 15px; } -.profile-field { +.extra-field { margin-bottom: 15px; } -.profile-field label { +.extra-field label { display: block; margin-bottom: 5px; font-weight: 500; @@ -235,7 +235,7 @@ color: #646970; } -.profile-field input { +.extra-field input { width: 100%; padding: 8px; font-size: 13px; diff --git a/build/extra-fields/block.json b/build/extra-fields/block.json new file mode 100644 index 000000000..81c721730 --- /dev/null +++ b/build/extra-fields/block.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "name": "activitypub/extra-fields", + "apiVersion": 3, + "version": "1.0.0", + "title": "Fediverse Extra Fields", + "category": "widgets", + "description": "Display extra fields from ActivityPub user profiles", + "textdomain": "activitypub", + "icon": "list-view", + "supports": { + "html": false, + "align": [ + "wide", + "full" + ], + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true, + "link": true + } + }, + "__experimentalBorder": { + "radius": true, + "width": true, + "color": true, + "style": true + }, + "shadow": true, + "typography": { + "fontSize": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "styles": [ + { + "name": "compact", + "label": "Compact", + "isDefault": true + }, + { + "name": "stacked", + "label": "Stacked" + }, + { + "name": "cards", + "label": "Cards" + } + ], + "attributes": { + "selectedUser": { + "type": "string", + "default": "blog" + }, + "maxFields": { + "type": "number", + "default": 0 + } + }, + "usesContext": [ + "postType", + "postId" + ], + "editorScript": "file:./index.js", + "style": "file:./style-index.css", + "render": "file:./render.php" +} \ No newline at end of file diff --git a/build/extra-fields/index.asset.php b/build/extra-fields/index.asset.php new file mode 100644 index 000000000..a2fa23f68 --- /dev/null +++ b/build/extra-fields/index.asset.php @@ -0,0 +1 @@ + array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '6f7cc2955f584ad618ba'); diff --git a/build/extra-fields/index.js b/build/extra-fields/index.js new file mode 100644 index 000000000..c34ddc779 --- /dev/null +++ b/build/extra-fields/index.js @@ -0,0 +1,2 @@ +(()=>{"use strict";var e,t={157:(e,t,i)=>{const r=window.wp.blocks,s=window.wp.i18n,l=window.wp.blockEditor,n=window.wp.components,a=window.wp.data,o=window.wp.element,c=window.wp.apiFetch;var d=i.n(c);function u(){return window._activityPubOptions||{}}const p=window.ReactJSXRuntime,h=JSON.parse('{"UU":"activitypub/extra-fields"}');(0,r.registerBlockType)(h.UU,{edit:function({attributes:e,setAttributes:t,context:i}){const{selectedUser:r,maxFields:c}=e,{postId:h,postType:v}=null!=i?i:{},[b,y]=(0,o.useState)([]),[f,x]=(0,o.useState)(!1),[g,w]=(0,o.useState)(null),m=(0,a.useSelect)(e=>{const t=e("core/editor"),i=e("core");if(h&&v&&i){var r,s;const e=null!==(r=i.getEditedEntityRecord?.("postType",v,h))&&void 0!==r?r:null;if(e?.author)return e.author;const t=null!==(s=i.getEntityRecord?.("postType",v,h))&&void 0!==s?s:null;if(t?.author)return t.author}return t&&t.getCurrentPostAttribute?t.getCurrentPostAttribute("author"):null},[h,v]),_="blog"===r?0:"inherit"===r?m||null:r,{namespace:j="activitypub/1.0",profileUrls:k={}}=u(),U="blog"===r?k.blog:k.user,F=(0,l.useBlockProps)({className:"activitypub-extra-fields-block-wrapper"}),C=function({withInherit:e=!1}){const{enabled:t,namespace:i}=u(),[r,l]=(0,o.useState)(!1),{fetchedUsers:n,isLoadingUsers:c}=(0,a.useSelect)(e=>{const{getUsers:i,getIsResolving:r}=e("core");return{fetchedUsers:t?.users?i({capabilities:"activitypub"}):null,isLoadingUsers:!!t?.users&&r("getUsers",[{capabilities:"activitypub"}])}},[]),p=(0,a.useSelect)(e=>n||c?null:e("core").getCurrentUser(),[n,c]);(0,o.useEffect)(()=>{n||c||!p||d()({path:`/${i}/actors/${p.id}`,method:"HEAD",headers:{Accept:"application/activity+json"},parse:!1}).then(()=>l(!0)).catch(()=>l(!1))},[n,c,p]);const h=n||(p&&r?[{id:p.id,name:p.name}]:[]);return(0,o.useMemo)(()=>{if(!h.length)return[];const i=[];return t?.blog&&n&&i.push({label:(0,s.__)("Blog","activitypub"),value:"blog"}),e&&t?.users&&n&&i.push({label:(0,s.__)("Dynamic User","activitypub"),value:"inherit"}),h.reduce((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e),i)},[h])}({withInherit:!0});(0,o.useEffect)(()=>{null!==_?(x(!0),w(null),d()({path:`/${j}/actors/${_}`,headers:{Accept:"application/activity+json"}}).then(e=>{const t=(e.attachment||[]).filter(e=>"PropertyValue"===e.type);y(t),x(!1)}).catch(e=>{w(e.message),x(!1)})):y([])},[_,j]);const O=c>0?b.slice(0,c):b,E=(()=>{const t=e.className?.includes("is-style-cards");if(!t)return{};const i=e.style||{},r=e.backgroundColor,s=i.color?.background;return r?{backgroundColor:`var(--wp--preset--color--${r})`}:s?{backgroundColor:s}:{}})(),S=(0,p.jsx)(l.InspectorControls,{children:(0,p.jsxs)(n.PanelBody,{title:(0,s.__)("Settings","activitypub"),initialOpen:!0,children:[(0,p.jsx)(n.SelectControl,{label:(0,s.__)("User","activitypub"),value:r,options:C,onChange:e=>t({selectedUser:e})}),(0,p.jsx)(n.RangeControl,{label:(0,s.__)("Maximum Fields","activitypub"),value:c,onChange:e=>t({maxFields:e}),min:0,max:20,help:(0,s.__)("Limit the number of fields displayed. 0 = show all.","activitypub")})]})});return"inherit"!==r||m?f?(0,p.jsx)("div",{...F,children:(0,p.jsx)(n.Placeholder,{label:(0,s.__)("Fediverse Extra Fields","activitypub"),children:(0,p.jsx)(n.Spinner,{})})}):g?(0,p.jsx)("div",{...F,children:(0,p.jsx)(n.Placeholder,{label:(0,s.__)("Fediverse Extra Fields","activitypub"),children:(0,p.jsx)("p",{children:(0,s.sprintf)(/* translators: %s: Error message */ /* translators: %s: Error message */ +(0,s.__)("Error loading extra fields: %s","activitypub"),g)})})}):0===O.length?(0,p.jsxs)(p.Fragment,{children:[S,(0,p.jsx)("div",{...F,children:(0,p.jsx)(n.Placeholder,{label:(0,s.__)("Fediverse Extra Fields","activitypub"),children:(0,p.jsxs)("p",{children:[(0,s.__)("No extra fields found.","activitypub")," ",U&&(0,p.jsx)(n.Button,{variant:"link",onClick:()=>{window.location.href=U},children:(0,s.__)("Add fields in your profile settings","activitypub")})]})})})]}):(0,p.jsxs)(p.Fragment,{children:[S,(0,p.jsx)("div",{...F,children:(0,p.jsx)("dl",{className:"activitypub-extra-fields",children:O.map(e=>(0,p.jsxs)("div",{className:"activitypub-extra-field",style:E,children:[(0,p.jsx)("dt",{children:e.name}),(0,p.jsx)("dd",{dangerouslySetInnerHTML:{__html:e.value}})]},`${e.name}-${e.value}`))})})]}):(0,p.jsxs)(p.Fragment,{children:[S,(0,p.jsx)("div",{...F,children:(0,p.jsx)(n.Placeholder,{label:(0,s.__)("Fediverse Extra Fields","activitypub"),children:(0,p.jsx)("p",{children:(0,s.__)("This block will display extra fields based on the post author when published.","activitypub")})})})]})},save:()=>null})}},i={};function r(e){var s=i[e];if(void 0!==s)return s.exports;var l=i[e]={exports:{}};return t[e](l,l.exports,r),l.exports}r.m=t,e=[],r.O=(t,i,s,l)=>{if(!i){var n=1/0;for(d=0;d=l)&&Object.keys(r.O).every(e=>r.O[e](i[o]))?i.splice(o--,1):(a=!1,l0&&e[d-1][2]>l;d--)e[d]=e[d-1];e[d]=[i,s,l]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var i in t)r.o(t,i)&&!r.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={914:0,678:0};r.O.j=t=>0===e[t];var t=(t,i)=>{var s,l,[n,a,o]=i,c=0;if(n.some(t=>0!==e[t])){for(s in a)r.o(a,s)&&(r.m[s]=a[s]);if(o)var d=o(r)}for(t&&t(i);cr(157));s=r.O(s)})(); \ No newline at end of file diff --git a/build/extra-fields/render.php b/build/extra-fields/render.php new file mode 100644 index 000000000..1f51a7285 --- /dev/null +++ b/build/extra-fields/render.php @@ -0,0 +1,81 @@ + 0 && count( $fields ) > $max_fields ) { + $fields = array_slice( $fields, 0, $max_fields ); +} + +// Return empty on frontend if no fields (hide block). +if ( empty( $fields ) ) { + return ''; +} + +// Get block wrapper attributes. +$wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => 'activitypub-extra-fields-block-wrapper', + ) +); + +// Extract background color for cards style. +$background_color = ''; +$card_style = ''; + +// Check if this is the cards style by looking at className attribute or wrapper classes. +$is_cards_style = ( isset( $attributes['className'] ) && str_contains( $attributes['className'], 'is-style-cards' ) ) + || str_contains( $wrapper_attributes, 'is-style-cards' ); + +if ( $is_cards_style ) { + // Check for background color in various formats. + if ( isset( $attributes['backgroundColor'] ) ) { + $background_color = sprintf( 'var(--wp--preset--color--%s)', $attributes['backgroundColor'] ); + } elseif ( isset( $attributes['style']['color']['background'] ) ) { + $background_color = $attributes['style']['color']['background']; + } + + if ( $background_color ) { + $card_style = sprintf( ' style="background-color: %s;"', esc_attr( $background_color ) ); + } +} +?> +
> +
+ +
> +
post_title ); ?>
+
+
+ +
+
diff --git a/build/extra-fields/style-index-rtl.css b/build/extra-fields/style-index-rtl.css new file mode 100644 index 000000000..4f1e363b3 --- /dev/null +++ b/build/extra-fields/style-index-rtl.css @@ -0,0 +1 @@ +.activitypub-extra-fields-block-wrapper.has-background .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.has-border .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.is-style-stacked.has-background .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.is-style-stacked.has-border .activitypub-extra-fields{padding:1rem}.activitypub-extra-fields-block-wrapper.is-style-cards.has-background .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.is-style-cards.has-border .activitypub-extra-fields{padding:0}.activitypub-extra-fields{display:table;list-style:none;margin:0;padding:0;table-layout:fixed}.activitypub-extra-field{display:table-row;margin-bottom:0}.activitypub-extra-field dt{color:inherit;display:table-cell;font-weight:600;margin-bottom:0;padding-bottom:.5em;padding-left:10px;text-overflow:ellipsis;vertical-align:baseline;white-space:nowrap}.activitypub-extra-field dt:after{content:":"}.activitypub-extra-field dd{color:inherit;display:table-cell;margin-bottom:0;margin-right:0;padding-bottom:.5em;vertical-align:baseline;word-break:break-word}.activitypub-extra-field dd p{margin-bottom:.5em;margin-top:0}.activitypub-extra-field dd p:last-child{margin-bottom:0}.activitypub-extra-field dd a{color:inherit;text-decoration:underline}.activitypub-extra-field dd a:hover{text-decoration:none}.is-style-stacked .activitypub-extra-fields{display:block;table-layout:auto}.is-style-stacked .activitypub-extra-field{display:block;margin-bottom:1em}.is-style-stacked .activitypub-extra-field:last-child{margin-bottom:0}.is-style-stacked .activitypub-extra-field dt{display:block;margin-bottom:.25em;padding-bottom:0;padding-left:0;text-overflow:clip;white-space:normal}.is-style-stacked .activitypub-extra-field dt:after{content:none}.is-style-stacked .activitypub-extra-field dd{display:block;padding-bottom:0}.is-style-cards.has-background{background:transparent!important}.is-style-cards.has-background .activitypub-extra-fields{padding:1rem}.is-style-cards .activitypub-extra-fields{display:block;table-layout:auto}.is-style-cards .activitypub-extra-field{background:var(--wp--preset--color--base,#fff);border:1px solid var(--wp--preset--color--contrast-2,#ddd);border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.05);display:block;margin-bottom:1em;padding:1em}.is-style-cards .activitypub-extra-field:last-child{margin-bottom:0}.is-style-cards .activitypub-extra-field dt{color:currentColor;display:block;font-size:.9em;letter-spacing:.5px;margin-bottom:.5em;padding-bottom:0;padding-left:0;text-transform:uppercase;white-space:normal}.is-style-cards .activitypub-extra-field dt:after{content:none}.is-style-cards .activitypub-extra-field dd{display:block;font-size:1em;padding-bottom:0} diff --git a/build/extra-fields/style-index.css b/build/extra-fields/style-index.css new file mode 100644 index 000000000..2c8cd64fa --- /dev/null +++ b/build/extra-fields/style-index.css @@ -0,0 +1 @@ +.activitypub-extra-fields-block-wrapper.has-background .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.has-border .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.is-style-stacked.has-background .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.is-style-stacked.has-border .activitypub-extra-fields{padding:1rem}.activitypub-extra-fields-block-wrapper.is-style-cards.has-background .activitypub-extra-fields,.activitypub-extra-fields-block-wrapper.is-style-cards.has-border .activitypub-extra-fields{padding:0}.activitypub-extra-fields{display:table;list-style:none;margin:0;padding:0;table-layout:fixed}.activitypub-extra-field{display:table-row;margin-bottom:0}.activitypub-extra-field dt{color:inherit;display:table-cell;font-weight:600;margin-bottom:0;padding-bottom:.5em;padding-right:10px;text-overflow:ellipsis;vertical-align:baseline;white-space:nowrap}.activitypub-extra-field dt:after{content:":"}.activitypub-extra-field dd{color:inherit;display:table-cell;margin-bottom:0;margin-left:0;padding-bottom:.5em;vertical-align:baseline;word-break:break-word}.activitypub-extra-field dd p{margin-bottom:.5em;margin-top:0}.activitypub-extra-field dd p:last-child{margin-bottom:0}.activitypub-extra-field dd a{color:inherit;text-decoration:underline}.activitypub-extra-field dd a:hover{text-decoration:none}.is-style-stacked .activitypub-extra-fields{display:block;table-layout:auto}.is-style-stacked .activitypub-extra-field{display:block;margin-bottom:1em}.is-style-stacked .activitypub-extra-field:last-child{margin-bottom:0}.is-style-stacked .activitypub-extra-field dt{display:block;margin-bottom:.25em;padding-bottom:0;padding-right:0;text-overflow:clip;white-space:normal}.is-style-stacked .activitypub-extra-field dt:after{content:none}.is-style-stacked .activitypub-extra-field dd{display:block;padding-bottom:0}.is-style-cards.has-background{background:transparent!important}.is-style-cards.has-background .activitypub-extra-fields{padding:1rem}.is-style-cards .activitypub-extra-fields{display:block;table-layout:auto}.is-style-cards .activitypub-extra-field{background:var(--wp--preset--color--base,#fff);border:1px solid var(--wp--preset--color--contrast-2,#ddd);border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.05);display:block;margin-bottom:1em;padding:1em}.is-style-cards .activitypub-extra-field:last-child{margin-bottom:0}.is-style-cards .activitypub-extra-field dt{color:currentColor;display:block;font-size:.9em;letter-spacing:.5px;margin-bottom:.5em;padding-bottom:0;padding-right:0;text-transform:uppercase;white-space:normal}.is-style-cards .activitypub-extra-field dt:after{content:none}.is-style-cards .activitypub-extra-field dd{display:block;font-size:1em;padding-bottom:0} diff --git a/includes/class-blocks.php b/includes/class-blocks.php index bc3e0f280..640867399 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -42,6 +42,10 @@ public static function enqueue_editor_assets() { 'blog' => ! is_user_type_disabled( 'blog' ), 'users' => ! is_user_type_disabled( 'user' ), ), + 'profileUrls' => array( + 'user' => \admin_url( 'profile.php#activitypub' ), + 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ), + ), ); wp_localize_script( 'wp-editor', '_activityPubOptions', $data ); @@ -76,6 +80,7 @@ public static function handle_in_reply_to_get_param() { * Register the blocks. */ public static function register_blocks() { + \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' ); \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' ); \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/followers' ); \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/reactions' ); diff --git a/src/extra-fields/__tests__/edit.test.js b/src/extra-fields/__tests__/edit.test.js new file mode 100644 index 000000000..d2a57315c --- /dev/null +++ b/src/extra-fields/__tests__/edit.test.js @@ -0,0 +1,541 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { useSelect } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; +import Edit from '../edit'; + +// Mock WordPress dependencies +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), +} ) ); + +jest.mock( '@wordpress/api-fetch' ); + +jest.mock( '@wordpress/i18n', () => ( { + __: ( text ) => text, + sprintf: ( format, ...args ) => { + let formatted = format; + args.forEach( ( arg, index ) => { + formatted = formatted.replace( /%s/, arg ); + } ); + return formatted; + }, +} ) ); + +jest.mock( '@wordpress/block-editor', () => ( { + useBlockProps: jest.fn( ( props ) => props ), + InspectorControls: ( { children } ) =>
{ children }
, +} ) ); + +jest.mock( '@wordpress/components', () => ( { + PanelBody: ( { children, title } ) => ( +
+ { children } +
+ ), + SelectControl: ( { label, value, options, onChange } ) => ( +
+ + +
+ ), + RangeControl: ( { label, value, onChange, min, max, help } ) => ( +
+ + onChange( parseInt( e.target.value, 10 ) ) } + /> + { help } +
+ ), + Placeholder: ( { label, children } ) => ( +
+ { children } +
+ ), + Spinner: () =>
Loading...
, + Button: ( { children, onClick, variant } ) => ( + + ), +} ) ); + +jest.mock( '../../shared/use-user-options', () => ( { + useUserOptions: jest.fn( () => [ + { value: 'blog', label: 'Blog' }, + { value: 'inherit', label: 'Inherit from post author' }, + { value: '1', label: 'Admin' }, + ] ), +} ) ); + +jest.mock( '../../shared/use-options', () => ( { + useOptions: jest.fn( () => ( { + namespace: 'activitypub/1.0', + profileUrls: { + user: '/wp-admin/profile.php#activitypub', + blog: '/wp-admin/options-general.php?page=activitypub&tab=blog-profile', + }, + } ) ), +} ) ); + +// Suppress console warnings for testing +const originalError = console.error; + +describe( 'Extra Fields Edit Component', () => { + let mockEditorStore; + let mockCoreStore; + + beforeAll( () => { + console.error = jest.fn(); + } ); + + afterAll( () => { + console.error = originalError; + } ); + + beforeEach( () => { + // Reset mocks before each test. + jest.clearAllMocks(); + + mockEditorStore = { + getCurrentPostAttribute: jest.fn().mockReturnValue( 1 ), + }; + + mockCoreStore = { + getEditedEntityRecord: jest.fn().mockReturnValue( null ), + getEntityRecord: jest.fn().mockReturnValue( null ), + }; + + useSelect.mockImplementation( ( callback ) => + callback( ( storeName ) => { + if ( 'core/editor' === storeName ) { + return mockEditorStore; + } + + if ( 'core' === storeName ) { + return mockCoreStore; + } + + return null; + } ) + ); + + // Default mock for apiFetch + apiFetch.mockResolvedValue( { + attachment: [ + { + type: 'PropertyValue', + name: 'Website', + value: 'example.com', + }, + { + type: 'PropertyValue', + name: 'Location', + value: 'San Francisco, CA', + }, + ], + } ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'renders inspector controls with user and max fields settings', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: 'blog', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'inspector-controls' ) ).toBeTruthy(); + } ); + + expect( screen.getByTestId( 'select-control' ) ).toBeTruthy(); + expect( screen.getByTestId( 'range-control' ) ).toBeTruthy(); + } ); + + test( 'fetches and displays extra fields', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + render( ); + + // Should show loading initially + expect( screen.getByTestId( 'spinner' ) ).toBeTruthy(); + + // Wait for data to load + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + expect( screen.getByText( 'Location' ) ).toBeTruthy(); + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/activitypub/1.0/actors/1', + headers: { Accept: 'application/activity+json' }, + } ); + } ); + + test( 'shows placeholder when inherit mode but no author', () => { + mockEditorStore.getCurrentPostAttribute.mockReturnValue( null ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: 'inherit', + maxFields: 0, + }; + + render( ); + + expect( screen.getByTestId( 'placeholder' ) ).toBeTruthy(); + expect( screen.getByTestId( 'inspector-controls' ) ).toBeTruthy(); + expect( + screen.getByText( 'This block will display extra fields based on the post author when published.' ) + ).toBeTruthy(); + } ); + + test( 'shows error message when API fetch fails', async () => { + apiFetch.mockRejectedValue( new Error( 'Network error' ) ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( screen.getByText( /Error loading extra fields/i ) ).toBeTruthy(); + } ); + + expect( screen.getByText( /Network error/i ) ).toBeTruthy(); + } ); + + test( 'shows empty state when no fields available', async () => { + apiFetch.mockResolvedValue( { + attachment: [], + } ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'placeholder' ) ).toBeTruthy(); + } ); + + expect( screen.getByText( 'No extra fields found.' ) ).toBeTruthy(); + expect( screen.getByText( 'Add fields in your profile settings' ) ).toBeTruthy(); + } ); + + test( 'limits displayed fields when maxFields is set', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 1, + }; + + render( ); + + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + // Should only show first field + expect( screen.queryByText( 'Location' ) ).toBeNull(); + } ); + + test( 'fetches blog user fields when selectedUser is blog', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: 'blog', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/activitypub/1.0/actors/0', + headers: { Accept: 'application/activity+json' }, + } ); + } ); + } ); + + test( 'fetches author fields when selectedUser is inherit', async () => { + mockEditorStore.getCurrentPostAttribute.mockReturnValue( 5 ); // authorId = 5 + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: 'inherit', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/activitypub/1.0/actors/5', + headers: { Accept: 'application/activity+json' }, + } ); + } ); + } ); + + test( 'uses context author when inherit mode is inside Query Loop', async () => { + mockEditorStore.getCurrentPostAttribute.mockReturnValue( null ); + mockCoreStore.getEditedEntityRecord.mockReturnValue( { + author: 9, + } ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: 'inherit', + maxFields: 0, + }; + + render( + + ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/activitypub/1.0/actors/9', + headers: { Accept: 'application/activity+json' }, + } ); + } ); + } ); + + test( 'filters attachment array to only PropertyValue types', async () => { + apiFetch.mockResolvedValue( { + attachment: [ + { + type: 'PropertyValue', + name: 'Website', + value: 'example.com', + }, + { + type: 'Link', + name: 'Other Link', + href: 'https://other.com', + }, + { + type: 'PropertyValue', + name: 'Location', + value: 'SF', + }, + ], + } ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + // Should show PropertyValue items + expect( screen.getByText( 'Location' ) ).toBeTruthy(); + + // Should not show Link type + expect( screen.queryByText( 'Other Link' ) ).toBeNull(); + } ); + + test( 'applies card style with background color when className includes is-style-cards', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + className: 'is-style-cards', + backgroundColor: 'primary', + }; + + const { container } = render( + + ); + + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + // Check if card style is applied + const fields = container.querySelectorAll( '.activitypub-extra-field' ); + expect( fields.length ).toBeGreaterThan( 0 ); + } ); + + test( 'applies card style with custom background color', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + className: 'is-style-cards', + style: { + color: { + background: '#ff0000', + }, + }, + }; + + const { container } = render( + + ); + + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + const fields = container.querySelectorAll( '.activitypub-extra-field' ); + expect( fields.length ).toBeGreaterThan( 0 ); + } ); + + test( 'does not apply background color when not cards style', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + className: 'is-style-compact', + backgroundColor: 'primary', + }; + + const { container } = render( + + ); + + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + const fields = container.querySelectorAll( '.activitypub-extra-field' ); + expect( fields.length ).toBeGreaterThan( 0 ); + } ); + + test( 'renders HTML content safely using dangerouslySetInnerHTML', async () => { + apiFetch.mockResolvedValue( { + attachment: [ + { + type: 'PropertyValue', + name: 'Website', + value: 'Click here', + }, + ], + } ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + const { container } = render( + + ); + + await waitFor( () => { + expect( screen.getByText( 'Website' ) ).toBeTruthy(); + } ); + + // Check that HTML is rendered + const link = container.querySelector( 'a[href="https://example.com"]' ); + expect( link ).toBeTruthy(); + expect( link.textContent ).toBe( 'Click here' ); + } ); + + test( 'refetches fields when userId changes', async () => { + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + const { rerender } = render( + + ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledTimes( 1 ); + } ); + + // Change user + rerender( + + ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledTimes( 2 ); + } ); + + expect( apiFetch ).toHaveBeenLastCalledWith( { + path: '/activitypub/1.0/actors/2', + headers: { Accept: 'application/activity+json' }, + } ); + } ); + + test( 'handles empty attachment array in actor response', async () => { + apiFetch.mockResolvedValue( { + // No attachment property + } ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: '1', + maxFields: 0, + }; + + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'placeholder' ) ).toBeTruthy(); + } ); + + expect( screen.getByText( 'No extra fields found.' ) ).toBeTruthy(); + expect( screen.getByText( 'Add fields in your profile settings' ) ).toBeTruthy(); + } ); + + test( 'does not fetch when userId is null', () => { + mockEditorStore.getCurrentPostAttribute.mockReturnValue( null ); + + const setAttributes = jest.fn(); + const attributes = { + selectedUser: 'inherit', + maxFields: 0, + }; + + render( ); + + // Should not call apiFetch + expect( apiFetch ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/src/extra-fields/block.json b/src/extra-fields/block.json new file mode 100644 index 000000000..fec400f06 --- /dev/null +++ b/src/extra-fields/block.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "name": "activitypub/extra-fields", + "apiVersion": 3, + "version": "1.0.0", + "title": "Fediverse Extra Fields", + "category": "widgets", + "description": "Display extra fields from ActivityPub user profiles", + "textdomain": "activitypub", + "icon": "list-view", + "supports": { + "html": false, + "align": [ "wide", "full" ], + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true, + "link": true + } + }, + "__experimentalBorder": { + "radius": true, + "width": true, + "color": true, + "style": true + }, + "shadow": true, + "typography": { + "fontSize": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "styles": [ + { + "name": "compact", + "label": "Compact", + "isDefault": true + }, + { + "name": "stacked", + "label": "Stacked" + }, + { + "name": "cards", + "label": "Cards" + } + ], + "attributes": { + "selectedUser": { + "type": "string", + "default": "blog" + }, + "maxFields": { + "type": "number", + "default": 0 + } + }, + "usesContext": [ "postType", "postId" ], + "editorScript": "file:./index.js", + "style": "file:./style-index.css", + "render": "file:./render.php" +} diff --git a/src/extra-fields/edit.js b/src/extra-fields/edit.js new file mode 100644 index 000000000..dc009a5b5 --- /dev/null +++ b/src/extra-fields/edit.js @@ -0,0 +1,269 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useBlockProps, InspectorControls } from '@wordpress/block-editor'; +import { PanelBody, SelectControl, RangeControl, Placeholder, Spinner, Button } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { useOptions } from '../shared/use-options'; +import { useUserOptions } from '../shared/use-user-options'; + +/** + * Editor component for Extra Fields block. + * + * @param {Object} props Component props. + * @param {Object} props.attributes Block attributes. + * @param {Function} props.setAttributes Function to set attributes. + * @param {Object} props.context Block context. + * @return {Element} Component element. + */ +export default function Edit( { attributes, setAttributes, context } ) { + const { selectedUser, maxFields } = attributes; + const { postId: contextPostId, postType: contextPostType } = context ?? {}; + const [ fields, setFields ] = useState( [] ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState( null ); + + // Get author ID from context or current post depending on editor. + const authorId = useSelect( + ( select ) => { + const editorStore = select( 'core/editor' ); + const coreStore = select( 'core' ); + + if ( contextPostId && contextPostType && coreStore ) { + const editedRecord = + coreStore.getEditedEntityRecord?.( 'postType', contextPostType, contextPostId ) ?? null; + if ( editedRecord?.author ) { + return editedRecord.author; + } + + const record = coreStore.getEntityRecord?.( 'postType', contextPostType, contextPostId ) ?? null; + if ( record?.author ) { + return record.author; + } + } + + if ( editorStore && editorStore.getCurrentPostAttribute ) { + return editorStore.getCurrentPostAttribute( 'author' ); + } + + return null; + }, + [ contextPostId, contextPostType ] + ); + + // Determine which user ID to fetch + const getUserId = () => { + if ( selectedUser === 'blog' ) { + return 0; + } + + if ( selectedUser === 'inherit' ) { + if ( authorId ) { + return authorId; + } + return null; + } + + return selectedUser; + }; + + const userId = getUserId(); + + // Get ActivityPub options + const { namespace = 'activitypub/1.0', profileUrls = {} } = useOptions(); + + // Select profile settings URL based on user type + const profileUrl = selectedUser === 'blog' ? profileUrls.blog : profileUrls.user; + + const blockProps = useBlockProps( { + className: 'activitypub-extra-fields-block-wrapper', + } ); + + // Get user options for dropdown + const userOptions = useUserOptions( { + withInherit: true, + } ); + + // Fetch extra fields + useEffect( () => { + if ( userId === null ) { + setFields( [] ); + return; + } + + setIsLoading( true ); + setError( null ); + + apiFetch( { + path: `/${ namespace }/actors/${ userId }`, + headers: { Accept: 'application/activity+json' }, + } ) + .then( ( actor ) => { + // Extract fields from attachment array + const attachments = actor.attachment || []; + // Filter to only PropertyValue types (the main format) + const propertyValues = attachments.filter( ( item ) => item.type === 'PropertyValue' ); + setFields( propertyValues ); + setIsLoading( false ); + } ) + .catch( ( err ) => { + setError( err.message ); + setIsLoading( false ); + } ); + }, [ userId, namespace ] ); + + // Apply max fields limit for preview + const displayFields = maxFields > 0 ? fields.slice( 0, maxFields ) : fields; + + // Extract background color for cards style + const getCardStyle = () => { + const isCardsStyle = attributes.className?.includes( 'is-style-cards' ); + if ( ! isCardsStyle ) { + return {}; + } + + // Get background color from block attributes + const style = attributes.style || {}; + const backgroundColor = attributes.backgroundColor; + const customColor = style.color?.background; + + if ( backgroundColor ) { + return { + backgroundColor: `var(--wp--preset--color--${ backgroundColor })`, + }; + } else if ( customColor ) { + return { + backgroundColor: customColor, + }; + } + + return {}; + }; + + const cardStyle = getCardStyle(); + + const settingsPanel = ( + + + setAttributes( { selectedUser: value } ) } + /> + setAttributes( { maxFields: value } ) } + min={ 0 } + max={ 20 } + help={ __( 'Limit the number of fields displayed. 0 = show all.', 'activitypub' ) } + /> + + + ); + + // Render placeholder if inherit mode but no author. Keep controls mounted for recovery. + if ( selectedUser === 'inherit' && ! authorId ) { + return ( + <> + { settingsPanel } +
+ +

+ { __( + 'This block will display extra fields based on the post author when published.', + 'activitypub' + ) } +

+
+
+ + ); + } + + // Render loading state + if ( isLoading ) { + return ( +
+ + + +
+ ); + } + + // Render error state + if ( error ) { + return ( +
+ +

+ { sprintf( + /* translators: %s: Error message */ + __( 'Error loading extra fields: %s', 'activitypub' ), + error + ) } +

+
+
+ ); + } + + // Render empty state + if ( displayFields.length === 0 ) { + return ( + <> + { settingsPanel } +
+ +

+ { __( 'No extra fields found.', 'activitypub' ) }{ ' ' } + { profileUrl && ( + + ) } +

+
+
+ + ); + } + + return ( + <> + { settingsPanel } +
+
+ { displayFields.map( ( field ) => ( +
+
{ field.name }
+
+
+ ) ) } +
+
+ + ); +} diff --git a/src/extra-fields/index.js b/src/extra-fields/index.js new file mode 100644 index 000000000..8823c1672 --- /dev/null +++ b/src/extra-fields/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import './style.scss'; + +/** + * Register the Extra Fields block. + * + * This block uses server-side rendering, so the save function returns null. + */ +registerBlockType( metadata.name, { + edit, + save: () => null, +} ); diff --git a/src/extra-fields/render.php b/src/extra-fields/render.php new file mode 100644 index 000000000..1f51a7285 --- /dev/null +++ b/src/extra-fields/render.php @@ -0,0 +1,81 @@ + 0 && count( $fields ) > $max_fields ) { + $fields = array_slice( $fields, 0, $max_fields ); +} + +// Return empty on frontend if no fields (hide block). +if ( empty( $fields ) ) { + return ''; +} + +// Get block wrapper attributes. +$wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => 'activitypub-extra-fields-block-wrapper', + ) +); + +// Extract background color for cards style. +$background_color = ''; +$card_style = ''; + +// Check if this is the cards style by looking at className attribute or wrapper classes. +$is_cards_style = ( isset( $attributes['className'] ) && str_contains( $attributes['className'], 'is-style-cards' ) ) + || str_contains( $wrapper_attributes, 'is-style-cards' ); + +if ( $is_cards_style ) { + // Check for background color in various formats. + if ( isset( $attributes['backgroundColor'] ) ) { + $background_color = sprintf( 'var(--wp--preset--color--%s)', $attributes['backgroundColor'] ); + } elseif ( isset( $attributes['style']['color']['background'] ) ) { + $background_color = $attributes['style']['color']['background']; + } + + if ( $background_color ) { + $card_style = sprintf( ' style="background-color: %s;"', esc_attr( $background_color ) ); + } +} +?> +
> +
+ +
> +
post_title ); ?>
+
+
+ +
+
diff --git a/src/extra-fields/style.scss b/src/extra-fields/style.scss new file mode 100644 index 000000000..b20081632 --- /dev/null +++ b/src/extra-fields/style.scss @@ -0,0 +1,178 @@ +/** + * Styles for the Extra Fields block. + */ + +.activitypub-extra-fields-block-wrapper { + // Add padding to content when background or border is set (stacked and compact styles) + &.has-background .activitypub-extra-fields, + &.has-border .activitypub-extra-fields, + &.is-style-stacked.has-background .activitypub-extra-fields, + &.is-style-stacked.has-border .activitypub-extra-fields { + padding: 1rem; + } + + // Remove default padding for cards style + &.is-style-cards.has-background .activitypub-extra-fields, + &.is-style-cards.has-border .activitypub-extra-fields { + padding: 0; + } +} + +.activitypub-extra-fields { + margin: 0; + padding: 0; + list-style: none; +} + +/** + * Default Compact Style + * Displays fields in a table-like format with aligned labels and values. + * This is the default style, so it applies without a style class. + */ +.activitypub-extra-fields { + display: table; + table-layout: fixed; +} + +.activitypub-extra-field { + display: table-row; + margin-bottom: 0; + + dt { + display: table-cell; + padding-bottom: 0.5em; + margin-bottom: 0; + padding-right: 10px; + white-space: nowrap; + vertical-align: baseline; + text-overflow: ellipsis; + font-weight: 600; + color: inherit; + + &::after { + content: ':'; + } + } + + dd { + display: table-cell; + padding-bottom: 0.5em; + vertical-align: baseline; + word-break: break-word; + margin-left: 0; + margin-bottom: 0; + color: inherit; + + p { + margin-top: 0; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + } +} + +/** + * Stacked Style + * Displays fields in a stacked format with label on top and value below. + */ +.is-style-stacked { + .activitypub-extra-fields { + display: block; + table-layout: auto; + } + + .activitypub-extra-field { + display: block; + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + + dt { + display: block; + padding-bottom: 0; + padding-right: 0; + margin-bottom: 0.25em; + white-space: normal; + text-overflow: clip; + + &::after { + content: none; + } + } + + dd { + display: block; + padding-bottom: 0; + } + } +} + +/** + * Cards Style + * Displays each field in its own bordered card with padding. + */ +.is-style-cards { + // Remove wrapper background for cards style - background goes on individual cards only + &.has-background { + background: transparent !important; + + .activitypub-extra-fields { + padding: 1rem; + } + } + + .activitypub-extra-fields { + display: block; + table-layout: auto; + } + + .activitypub-extra-field { + display: block; + border: 1px solid var(--wp--preset--color--contrast-2, #ddd); + border-radius: 8px; + padding: 1em; + margin-bottom: 1em; + background: var(--wp--preset--color--base, #fff); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + + &:last-child { + margin-bottom: 0; + } + + dt { + display: block; + font-size: 0.9em; + text-transform: uppercase; + letter-spacing: 0.5px; + color: currentColor; + margin-bottom: 0.5em; + padding-bottom: 0; + padding-right: 0; + white-space: normal; + + &::after { + content: none; + } + } + + dd { + display: block; + font-size: 1em; + padding-bottom: 0; + } + } +} diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php index 9579994a1..0553c57d1 100644 --- a/tests/phpunit/tests/includes/class-test-blocks.php +++ b/tests/phpunit/tests/includes/class-test-blocks.php @@ -8,6 +8,7 @@ namespace Activitypub\Tests; use Activitypub\Blocks; +use Activitypub\Collection\Extra_Fields; use Activitypub\Collection\Interactions; use function Activitypub\object_to_uri; @@ -18,6 +19,75 @@ * @coversDefaultClass \Activitypub\Blocks */ class Test_Blocks extends \WP_UnitTestCase { + + /** + * User ID for Extra Fields block tests. + * + * @var int + */ + private static $extra_fields_user_id; + + /** + * Set up before class. + * + * @param \WP_UnitTest_Factory $factory Factory instance. + */ + public static function wpSetUpBeforeClass( $factory ) { + // Create test user for Extra Fields block tests. + self::$extra_fields_user_id = $factory->user->create( + array( + 'user_login' => 'extrafieldsuser', + 'user_email' => 'extrafields@example.com', + ) + ); + + // Create some extra fields for the user. + $factory->post->create( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'post_title' => 'Website', + 'post_content' => '

example.com

', + 'post_status' => 'publish', + 'post_author' => self::$extra_fields_user_id, + 'menu_order' => 10, + ) + ); + + $factory->post->create( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'post_title' => 'Location', + 'post_content' => '

San Francisco, CA

', + 'post_status' => 'publish', + 'post_author' => self::$extra_fields_user_id, + 'menu_order' => 20, + ) + ); + + $factory->post->create( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'post_title' => 'Pronouns', + 'post_content' => '

they/them

', + 'post_status' => 'publish', + 'post_author' => self::$extra_fields_user_id, + 'menu_order' => 30, + ) + ); + + // Create extra fields for blog. + $factory->post->create( + array( + 'post_type' => Extra_Fields::BLOG_POST_TYPE, + 'post_title' => 'Blog Website', + 'post_content' => '

blog.example.com

', + 'post_status' => 'publish', + 'post_author' => self::$extra_fields_user_id, + 'menu_order' => 10, + ) + ); + } + /** * Test register_post_meta. * @@ -498,4 +568,150 @@ public function filter_pleroma_object( $response, $url ) { return $response; } + + /** + * Test Extra Fields block rendering with blog user. + * + * @covers ::get_user_id + * @covers \Activitypub\Collection\Extra_Fields::get_actor_fields + * @covers \Activitypub\Collection\Extra_Fields::get_formatted_content + */ + public function test_render_extra_fields_block_with_blog_user() { + $block_markup = ''; + $output = do_blocks( $block_markup ); + + $this->assertStringContainsString( 'activitypub-extra-fields-block-wrapper', $output ); + $this->assertStringContainsString( 'Blog Website', $output ); + $this->assertStringContainsString( 'blog.example.com', $output ); + } + + /** + * Test Extra Fields block rendering with specific user ID. + * + * @covers ::get_user_id + * @covers \Activitypub\Collection\Extra_Fields::get_actor_fields + * @covers \Activitypub\Collection\Extra_Fields::get_formatted_content + */ + public function test_render_extra_fields_block_with_specific_user() { + $block_markup = sprintf( + '', + self::$extra_fields_user_id + ); + $output = do_blocks( $block_markup ); + + $this->assertStringContainsString( 'Website', $output ); + $this->assertStringContainsString( 'example.com', $output ); + $this->assertStringContainsString( 'Location', $output ); + $this->assertStringContainsString( 'San Francisco, CA', $output ); + $this->assertStringContainsString( 'Pronouns', $output ); + $this->assertStringContainsString( 'they/them', $output ); + } + + /** + * Test Extra Fields block maxFields attribute limits output. + * + * @covers ::get_user_id + * @covers \Activitypub\Collection\Extra_Fields::get_actor_fields + */ + public function test_render_extra_fields_block_with_max_fields() { + $block_markup = sprintf( + '', + self::$extra_fields_user_id + ); + $output = do_blocks( $block_markup ); + + // Should contain first two fields. + $this->assertStringContainsString( 'Website', $output ); + $this->assertStringContainsString( 'Location', $output ); + + // Should not contain third field. + $this->assertStringNotContainsString( 'Pronouns', $output ); + $this->assertStringNotContainsString( 'they/them', $output ); + } + + /** + * Test Extra Fields block with no extra fields returns empty. + * + * @covers ::get_user_id + * @covers \Activitypub\Collection\Extra_Fields::get_actor_fields + */ + public function test_render_extra_fields_block_with_no_fields() { + $user_id = self::factory()->user->create( + array( + 'user_login' => 'emptyuser', + 'user_email' => 'empty@example.com', + ) + ); + + // Prevent default extra fields from being created. + add_filter( + 'activitypub_get_actor_extra_fields', + function ( $fields, $uid ) use ( $user_id ) { + if ( $uid === $user_id ) { + return array(); + } + return $fields; + }, + 10, + 2 + ); + + $block_markup = sprintf( + '', + $user_id + ); + $output = do_blocks( $block_markup ); + + $this->assertEmpty( $output ); + + remove_all_filters( 'activitypub_get_actor_extra_fields' ); + } + + /** + * Test Extra Fields block with cards style and background color. + * + * @covers ::get_user_id + * @covers \Activitypub\Collection\Extra_Fields::get_actor_fields + */ + public function test_render_extra_fields_block_with_cards_style() { + $block_markup = sprintf( + '', + self::$extra_fields_user_id + ); + $output = do_blocks( $block_markup ); + + $this->assertStringContainsString( 'is-style-cards', $output ); + $this->assertStringContainsString( 'var(--wp--preset--color--primary)', $output ); + } + + /** + * Test Extra Fields block preserves HTML in field content. + * + * @covers ::get_user_id + * @covers \Activitypub\Collection\Extra_Fields::get_actor_fields + * @covers \Activitypub\Collection\Extra_Fields::get_formatted_content + */ + public function test_render_extra_fields_block_preserves_html() { + $field_id = self::factory()->post->create( + array( + 'post_type' => Extra_Fields::USER_POST_TYPE, + 'post_title' => 'Rich Content', + 'post_content' => '

Visit my site at test.com

', + 'post_status' => 'publish', + 'post_author' => self::$extra_fields_user_id, + 'menu_order' => 40, + ) + ); + + $block_markup = sprintf( + '', + self::$extra_fields_user_id + ); + $output = do_blocks( $block_markup ); + + $this->assertStringContainsString( 'my site', $output ); + $this->assertStringContainsString( '