diff --git a/.distignore b/.distignore index 782f2e8..55d2ee9 100644 --- a/.distignore +++ b/.distignore @@ -36,3 +36,4 @@ phpunit.xml.dist tests node_modules vendor +src diff --git a/activitypub.php b/activitypub.php index 28aa99c..a8d3a94 100644 --- a/activitypub.php +++ b/activitypub.php @@ -32,6 +32,7 @@ function init() { \define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_FILE', plugin_dir_path( __FILE__ ) . '/' . basename( __FILE__ ) ); + \define( 'ACTIVITYPUB_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); Migration::init(); Activitypub::init(); @@ -50,6 +51,7 @@ function init() { Admin::init(); Hashtag::init(); Shortcodes::init(); + Blocks::init(); Mention::init(); Health_Check::init(); Scheduler::init(); diff --git a/assets/img/mp.jpg b/assets/img/mp.jpg new file mode 100644 index 0000000..0356f91 Binary files /dev/null and b/assets/img/mp.jpg differ diff --git a/build/followers/block.json b/build/followers/block.json new file mode 100644 index 0000000..c7015d3 --- /dev/null +++ b/build/followers/block.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "name": "activitypub/followers", + "apiVersion": 3, + "version": "1.0.0", + "title": "Fediverse Followers", + "category": "widgets", + "description": "Display your followers from the Fediverse on your website.", + "textdomain": "activitypub", + "icon": "groups", + "supports": { + "html": false + }, + "attributes": { + "title": { + "type": "string", + "default": "Fediverse Followers" + }, + "selectedUser": { + "type": "string", + "default": "site" + }, + "per_page": { + "type": "number", + "default": 10 + }, + "order": { + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ] + } + }, + "styles": [ + { + "name": "default", + "label": "No Lines", + "isDefault": true + }, + { + "name": "with-lines", + "label": "Lines" + }, + { + "name": "compact", + "label": "Compact" + } + ], + "editorScript": "file:./index.js", + "viewScript": "file:./view.js", + "style": [ + "file:./style-view.css", + "wp-block-query-pagination" + ] +} \ No newline at end of file diff --git a/build/followers/index.asset.php b/build/followers/index.asset.php new file mode 100644 index 0000000..245278a --- /dev/null +++ b/build/followers/index.asset.php @@ -0,0 +1 @@ + array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '2879986bf189fb73965e'); diff --git a/build/followers/index.js b/build/followers/index.js new file mode 100644 index 0000000..4acf0fe --- /dev/null +++ b/build/followers/index.js @@ -0,0 +1,3 @@ +(()=>{var e={184:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e=[],t=0;t{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e=window.wp.blocks,t=window.wp.element,r=window.wp.primitives,n=(0,t.createElement)(r.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24"},(0,t.createElement)(r.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"}));function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t{e.preventDefault(),!a&&l(n)}},r)}const g={outlined:"outlined",minimal:"minimal"};function y(e){let{compact:a,nextLabel:r,page:n,pageClick:l,perPage:o,prevLabel:i,total:c,variant:s=g.outlined}=e;const p=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(n,Math.ceil(c/o)),u=w()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${s}`,{"is-compact":a});return(0,t.createElement)("nav",{className:u},i&&(0,t.createElement)(d,{key:"prev",page:n-1,pageClick:l,active:1===n,"aria-label":i,className:"wp-block-query-pagination-previous block-editor-block-list__block"},i),!a&&(0,t.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},p.map((e=>(0,t.createElement)(d,{key:e,page:e,pageClick:l,active:e===n,className:"page-numbers"},e)))),r&&(0,t.createElement)(d,{key:"next",page:n+1,pageClick:l,active:n===Math.ceil(c/o),"aria-label":r,className:"wp-block-query-pagination-next block-editor-block-list__block"},r))}const{namespace:f}=window._activityPubOptions;function h(e){let{selectedUser:a,per_page:r,order:n,title:l,page:o,setPage:i,className:c=""}=e;const u="site"===a?0:a,[b,w]=(0,p.useState)([]),[d,g]=(0,p.useState)(0),[h,E]=(0,p.useState)(0),[_,x]=function(){const[e,t]=(0,p.useState)(1);return[e,t]}(),S=o||_,C=i||x,N=(0,t.createInterpolateElement)(/* translators: arrow for previous followers link */ +(0,s.__)(" Less","activitypub"),{span:(0,t.createElement)("span",{class:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),O=(0,t.createInterpolateElement)(/* translators: arrow for next followers link */ +(0,s.__)("More ","activitypub"),{span:(0,t.createElement)("span",{class:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})});return(0,p.useEffect)((()=>{const e=function(e,t,a,r){const n=`/${f}/users/${e}/followers`,l={per_page:t,order:a,page:r,context:"full"};return(0,v.addQueryArgs)(n,l)}(u,r,n,S);m()({path:e}).then((e=>{g(Math.ceil(e.totalItems/r)),E(e.totalItems),w(e.orderedItems)})).catch((e=>console.error(e)))}),[u,r,n,S]),(0,t.createElement)("div",{className:"activitypub-follower-block "+c},(0,t.createElement)("h3",null,l),(0,t.createElement)("ul",null,b&&b.map((e=>(0,t.createElement)("li",{key:e.url},(0,t.createElement)(k,e))))),d>1&&(0,t.createElement)(y,{page:S,perPage:r,total:h,pageClick:C,nextLabel:O,prevLabel:N,compact:"is-style-compact"===c}))}function k(e){let{name:a,icon:r,url:n,preferredUsername:l}=e;const i=`@${l}`;return(0,t.createElement)(o.ExternalLink,{className:"activitypub-link",href:n,title:i,onClick:e=>e.preventDefault()},(0,t.createElement)("img",{width:"40",height:"40",src:r.url,class:"avatar activitypub-avatar"}),(0,t.createElement)("span",{class:"activitypub-actor"},(0,t.createElement)("strong",{className:"activitypub-name"},a),(0,t.createElement)("span",{class:"sep"},"/"),(0,t.createElement)("span",{class:"activitypub-handle"},i)))}(0,e.registerBlockType)("activitypub/followers",{edit:function(e){let{attributes:a,setAttributes:r}=e;const{order:n,per_page:p,selectedUser:u,className:m}=a,v=(0,c.useBlockProps)(),[b,w]=(0,t.useState)(1),d=[{label:(0,s.__)("New to old","activitypub"),value:"desc"},{label:(0,s.__)("Old to new","activitypub"),value:"asc"}],g=(0,i.useSelect)((e=>e("core").getUsers({who:"authors"}))),y=(0,t.useMemo)((()=>{if(!g)return[];const e=[{label:(0,s.__)("Whole Site","activitypub"),value:"site"}];return g.reduce(((e,t)=>(e.push({label:t.name,value:t.id}),e)),e)}),[g]),f=e=>t=>{w(1),r({[e]:t})};return(0,t.createElement)("div",v,(0,t.createElement)(c.InspectorControls,{key:"setting"},(0,t.createElement)(o.PanelBody,{title:(0,s.__)("Followers Options","activitypub")},(0,t.createElement)(o.SelectControl,{label:(0,s.__)("Select User","activitypub"),value:u,options:y,onChange:f("selectedUser")}),(0,t.createElement)(o.SelectControl,{label:(0,s.__)("Sort","activitypub"),value:n,options:d,onChange:f("order")}),(0,t.createElement)(o.RangeControl,{label:(0,s.__)("Number of Followers","activitypub"),value:p,onChange:f("per_page"),min:1,max:10}))),(0,t.createElement)(h,l({},a,{page:b,setPage:w})))},save:()=>null,icon:n})})()})(); \ No newline at end of file diff --git a/build/followers/style-view.css b/build/followers/style-view.css new file mode 100644 index 0000000..824879e --- /dev/null +++ b/build/followers/style-view.css @@ -0,0 +1 @@ +.activitypub-follower-block.is-style-compact .activitypub-handle,.activitypub-follower-block.is-style-compact .sep{display:none}.activitypub-follower-block.is-style-with-lines ul li{border-bottom:.5px solid;margin-bottom:.5rem;padding-bottom:.5rem}.activitypub-follower-block.is-style-with-lines ul li:last-child{border-bottom:none}.activitypub-follower-block.is-style-with-lines .activitypub-handle,.activitypub-follower-block.is-style-with-lines .activitypub-name{text-decoration:none}.activitypub-follower-block.is-style-with-lines .activitypub-handle:hover,.activitypub-follower-block.is-style-with-lines .activitypub-name:hover{text-decoration:underline}.activitypub-follower-block ul{margin:0!important;padding:0!important}.activitypub-follower-block li{display:flex;margin-bottom:1rem}.activitypub-follower-block img{border-radius:50%;height:40px;margin-right:var(--wp--preset--spacing--20,.5rem);width:40px}.activitypub-follower-block .activitypub-link{align-items:center;color:inherit!important;display:flex;flex-flow:row nowrap;max-width:100%;text-decoration:none!important}.activitypub-follower-block .activitypub-handle,.activitypub-follower-block .activitypub-name{text-decoration:underline;text-decoration-thickness:.8px;text-underline-position:under}.activitypub-follower-block .activitypub-handle:hover,.activitypub-follower-block .activitypub-name:hover{text-decoration:none}.activitypub-follower-block .activitypub-name{font-size:var(--wp--preset--font-size--normal,16px)}.activitypub-follower-block .activitypub-actor{font-size:var(--wp--preset--font-size--small,13px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follower-block .sep{padding:0 .2rem}.activitypub-follower-block .wp-block-query-pagination{margin-top:1.5rem}.activitypub-follower-block .activitypub-pager{cursor:default}.activitypub-follower-block .activitypub-pager.current{opacity:.33}.activitypub-follower-block .page-numbers{padding:0 .2rem}.activitypub-follower-block .page-numbers.current{font-weight:700;opacity:1} diff --git a/build/followers/view.asset.php b/build/followers/view.asset.php new file mode 100644 index 0000000..5c497bd --- /dev/null +++ b/build/followers/view.asset.php @@ -0,0 +1 @@ + array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => 'aa55e14b87c4b4e1c1a3'); diff --git a/build/followers/view.js b/build/followers/view.js new file mode 100644 index 0000000..f88f890 --- /dev/null +++ b/build/followers/view.js @@ -0,0 +1,3 @@ +(()=>{var e,t={142:(e,t,a)=>{"use strict";const r=window.wp.element,n=window.React,l=window.wp.apiFetch;var i=a.n(l);const o=window.wp.url,c=window.wp.i18n;var s=a(184),p=a.n(s);function u(e){let{active:t,children:a,page:n,pageClick:l,className:i}=e;const o=p()("wp-block activitypub-pager",i,{current:t});return(0,r.createElement)("a",{className:o,onClick:e=>{e.preventDefault(),!t&&l(n)}},a)}const m={outlined:"outlined",minimal:"minimal"};function v(e){let{compact:t,nextLabel:a,page:n,pageClick:l,perPage:i,prevLabel:o,total:c,variant:s=m.outlined}=e;const v=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(n,Math.ceil(c/i)),b=p()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${s}`,{"is-compact":t});return(0,r.createElement)("nav",{className:b},o&&(0,r.createElement)(u,{key:"prev",page:n-1,pageClick:l,active:1===n,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!t&&(0,r.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},v.map((e=>(0,r.createElement)(u,{key:e,page:e,pageClick:l,active:e===n,className:"page-numbers"},e)))),a&&(0,r.createElement)(u,{key:"next",page:n+1,pageClick:l,active:n===Math.ceil(c/i),"aria-label":a,className:"wp-block-query-pagination-next block-editor-block-list__block"},a))}const b=window.wp.components,{namespace:d}=window._activityPubOptions;function f(e){let{selectedUser:t,per_page:a,order:l,title:s,page:p,setPage:u,className:m=""}=e;const b="site"===t?0:t,[f,w]=(0,n.useState)([]),[y,k]=(0,n.useState)(0),[h,E]=(0,n.useState)(0),[x,_]=function(){const[e,t]=(0,n.useState)(1);return[e,t]}(),O=p||x,N=u||_,S=(0,r.createInterpolateElement)(/* translators: arrow for previous followers link */ +(0,c.__)(" Less","activitypub"),{span:(0,r.createElement)("span",{class:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),C=(0,r.createInterpolateElement)(/* translators: arrow for next followers link */ +(0,c.__)("More ","activitypub"),{span:(0,r.createElement)("span",{class:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})});return(0,n.useEffect)((()=>{const e=function(e,t,a,r){const n=`/${d}/users/${e}/followers`,l={per_page:t,order:a,page:r,context:"full"};return(0,o.addQueryArgs)(n,l)}(b,a,l,O);i()({path:e}).then((e=>{k(Math.ceil(e.totalItems/a)),E(e.totalItems),w(e.orderedItems)})).catch((e=>console.error(e)))}),[b,a,l,O]),(0,r.createElement)("div",{className:"activitypub-follower-block "+m},(0,r.createElement)("h3",null,s),(0,r.createElement)("ul",null,f&&f.map((e=>(0,r.createElement)("li",{key:e.url},(0,r.createElement)(g,e))))),y>1&&(0,r.createElement)(v,{page:O,perPage:a,total:h,pageClick:N,nextLabel:C,prevLabel:S,compact:"is-style-compact"===m}))}function g(e){let{name:t,icon:a,url:n,preferredUsername:l}=e;const i=`@${l}`;return(0,r.createElement)(b.ExternalLink,{className:"activitypub-link",href:n,title:i,onClick:e=>e.preventDefault()},(0,r.createElement)("img",{width:"40",height:"40",src:a.url,class:"avatar activitypub-avatar"}),(0,r.createElement)("span",{class:"activitypub-actor"},(0,r.createElement)("strong",{className:"activitypub-name"},t),(0,r.createElement)("span",{class:"sep"},"/"),(0,r.createElement)("span",{class:"activitypub-handle"},i)))}const w=window.wp.domReady;a.n(w)()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-follower-block"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,r.render)((0,r.createElement)(f,t),e)}))}))},184:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e=[],t=0;t{if(!a){var i=1/0;for(p=0;p=l)&&Object.keys(r.O).every((e=>r.O[e](a[c])))?a.splice(c--,1):(o=!1,l0&&e[p-1][2]>l;p--)e[p]=e[p-1];e[p]=[a,n,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 a in t)r.o(t,a)&&!r.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={638:0,962:0};r.O.j=t=>0===e[t];var t=(t,a)=>{var n,l,[i,o,c]=a,s=0;if(i.some((t=>0!==e[t]))){for(n in o)r.o(o,n)&&(r.m[n]=o[n]);if(c)var p=c(r)}for(t&&t(a);sr(142)));n=r.O(n)})(); \ No newline at end of file diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 9c5f52a..8ed6699 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -8,6 +8,7 @@ namespace Activitypub\Activity; use WP_Error; +use ReflectionClass; use function Activitypub\camel_to_snake_case; use function Activitypub\snake_to_camel_case; @@ -448,9 +449,10 @@ class Base_Object { $var = \strtolower( \substr( $method, 4 ) ); if ( \strncasecmp( $method, 'get', 3 ) === 0 ) { - if ( $this->has( $var ) ) { - return $this->get( $var ); + if ( ! $this->has( $var ) ) { + return new WP_Error( 'invalid_key', 'Invalid key' ); } + return $this->$var; } @@ -493,7 +495,7 @@ class Base_Object { return new WP_Error( 'invalid_key', 'Invalid key' ); } - return $this->$key; + return call_user_func( array( $this, 'get_' . $key ) ); } /** @@ -644,7 +646,7 @@ class Base_Object { $array = array_merge( array( '@context' => $context ), $array ); } - $class = new \ReflectionClass( $this ); + $class = new ReflectionClass( $this ); $class = strtolower( $class->getShortName() ); $array = \apply_filters( 'activitypub_activity_object_array', $array, $class, $this->id, $this ); diff --git a/includes/class-blocks.php b/includes/class-blocks.php new file mode 100644 index 0000000..ffc91a5 --- /dev/null +++ b/includes/class-blocks.php @@ -0,0 +1,90 @@ + ACTIVITYPUB_REST_NAMESPACE, + ); + $js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) ); + \wp_add_inline_script( $handle, $js, 'before' ); + } + + public static function register_blocks() { + \register_block_type_from_metadata( + ACTIVITYPUB_PLUGIN_DIR . '/build/followers', + array( + 'render_callback' => array( self::class, 'render_follower_block' ), + ) + ); + } + + private static function get_user_id( $user_string ) { + if ( is_numeric( $user_string ) ) { + return absint( $user_string ); + } + // any other non-numeric falls back to 0, including the `site` string used in the UI + return 0; + } + + public static function render_follower_block( $attrs, $content, $block ) { + $followee_user_id = self::get_user_id( $attrs['selectedUser'] ); + $per_page = absint( $attrs['per_page'] ); + $followers = Followers::get_followers( $followee_user_id, $per_page ); + $title = $attrs['title']; + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'aria-label' => __( 'Fediverse Followers', 'activitypub' ), + 'class' => 'activitypub-follower-block', + 'data-attrs' => wp_json_encode( $attrs ), + ) + ); + + $html = '
'; + if ( $title ) { + $html .= '

' . $title . '

'; + } + $html .= '
    '; + foreach ( $followers as $follower ) { + $html .= '
  • ' . self::render_follower( $follower ) . '
  • '; + } + // We are only pagination on the JS side. Could be revisited but we gotta ship! + $html .= '
'; + return $html; + } + + public static function render_follower( $follower ) { + $external_svg = ''; + $template = + ' + + + %s + / + @%s + + %s + '; + + $data = $follower->to_array(); + + return sprintf( + $template, + esc_url( $data['url'] ), + esc_attr( $data['name'] ), + esc_attr( $data['icon']['url'] ), + esc_html( $data['name'] ), + esc_html( $data['preferredUsername'] ), + $external_svg + ); + } +} diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index f5527f1..00a4718 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -292,14 +292,30 @@ class Followers { /** * Get the Followers of a given user * - * @param int $user_id The ID of the WordPress User - * @param string $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT - * @param int $number Limts the result - * @param int $offset Offset - * - * @return array The Term list of Followers, the format depends on $output + * @param int $user_id The ID of the WordPress User. + * @param int $number Maximum number of results to return. + * @param int $page Page number. + * @param array $args The WP_Query arguments. + * @return array List of `Follower` objects. */ public static function get_followers( $user_id, $number = -1, $page = null, $args = array() ) { + $data = self::get_followers_with_count( $user_id, $number, $page, $args ); + return $data['followers']; + } + + /** + * Get the Followers of a given user, along with a total count for pagination purposes. + * + * @param int $user_id The ID of the WordPress User. + * @param int $number Maximum number of results to return. + * @param int $page Page number. + * @param array $args The WP_Query arguments. + * + * @return array + * followers List of `Follower` objects. + * total Total number of followers. + */ + public static function get_followers_with_count( $user_id, $number = -1, $page = null, $args = array() ) { $defaults = array( 'post_type' => self::POST_TYPE, 'posts_per_page' => $number, @@ -314,16 +330,16 @@ class Followers { ), ); - $args = wp_parse_args( $args, $defaults ); + $args = wp_parse_args( $args, $defaults ); $query = new WP_Query( $args ); - $posts = $query->get_posts(); - $items = array(); - - foreach ( $posts as $post ) { - $items[] = Follower::init_from_cpt( $post ); // phpcs:ignore - } - - return $items; + $total = $query->found_posts; + $followers = array_map( + function( $post ) { + return Follower::init_from_cpt( $post ); + }, + $query->get_posts() + ); + return compact( 'followers', 'total' ); } /** diff --git a/includes/model/class-follower.php b/includes/model/class-follower.php index 39018e2..0c5e9b1 100644 --- a/includes/model/class-follower.php +++ b/includes/model/class-follower.php @@ -171,6 +171,55 @@ class Follower extends Actor { return $meta_input; } + /** + * Get the icon. + * + * Sets a fallback to better handle API and HTML outputs. + * + * @return array The icon. + */ + public function get_icon() { + if ( isset( $this->icon['url'] ) ) { + return $this->icon; + } + + return array( + 'type' => 'Image', + 'mediaType' => 'image/jpeg', + 'url' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg', + ); + } + + /** + * Get Name. + * + * Tries to extract a name from the URL or ID if not set. + * + * @return string The name. + */ + public function get_name() { + if ( isset( $this->name ) ) { + return $this->name; + } + + return $this->extract_name_from_uri(); + } + + /** + * The preferred Username. + * + * Tries to extract a name from the URL or ID if not set. + * + * @return string The preferred Username. + */ + public function get_preferred_username() { + if ( isset( $this->name ) ) { + return $this->name; + } + + return $this->extract_name_from_uri(); + } + /** * Get the Icon URL (Avatar) * @@ -222,4 +271,47 @@ class Follower extends Actor { return $object; } + + /** + * Infer a shortname from the Actor ID or URL. Used only for fallbacks, + * we will try to use what's supplied. + * + * @return string Hopefully the name of the Follower. + */ + protected function extract_name_from_uri() { + // prefer the URL, but fall back to the ID. + if ( $this->url ) { + $name = $this->url; + } else { + $name = $this->id; + } + + if ( \filter_var( $name, FILTER_VALIDATE_URL ) ) { + $name = \rtrim( $name, '/' ); + $path = \wp_parse_url( $name, PHP_URL_PATH ); + + if ( $path ) { + if ( \strpos( $name, '@' ) !== false ) { + // expected: https://example.com/@user (default URL pattern) + $name = \preg_replace( '|^/@?|', '', $path ); + } else { + // expected: https://example.com/users/user (default ID pattern) + $parts = \explode( '/', $path ); + $name = \array_pop( $parts ); + } + } + } elseif ( + \is_email( $name ) || + \strpos( $name, 'acct' ) === 0 || + \strpos( $name, '@' ) === 0 + ) { + // expected: user@example.com or acct:user@example (WebFinger) + $name = \ltrim( $name, '@' ); + $name = \ltrim( $name, 'acct:' ); + $parts = \explode( '@', $name ); + $name = $parts[0]; + } + + return $name; + } } diff --git a/includes/rest/class-followers.php b/includes/rest/class-followers.php index a07514e..c125fcd 100644 --- a/includes/rest/class-followers.php +++ b/includes/rest/class-followers.php @@ -58,13 +58,17 @@ class Followers { return $user; } - $page = $request->get_param( 'page', 1 ); + $order = $request->get_param( 'order' ); + $per_page = (int) $request->get_param( 'per_page' ); + $page = (int) $request->get_param( 'page' ); + $context = $request->get_param( 'context' ); /* * Action triggerd prior to the ActivityPub profile being created and sent to the client */ \do_action( 'activitypub_rest_followers_pre' ); + $data = Follower_Collection::get_followers_with_count( $user_id, $per_page, $page, array( 'order' => ucwords( $order ) ) ); $json = new stdClass(); $json->{'@context'} = \Activitypub\get_context(); @@ -74,13 +78,13 @@ class Followers { $json->actor = $user->get_id(); $json->type = 'OrderedCollectionPage'; - $json->totalItems = Follower_Collection::count_followers( $user->get__id() ); // phpcs:ignore + $json->totalItems = $data['total']; // phpcs:ignore $json->partOf = get_rest_url_by_path( sprintf( 'users/%d/followers', $user->get__id() ) ); // phpcs:ignore $json->first = \add_query_arg( 'page', 1, $json->partOf ); // phpcs:ignore - $json->last = \add_query_arg( 'page', \ceil ( $json->totalItems / 20 ), $json->partOf ); // phpcs:ignore + $json->last = \add_query_arg( 'page', \ceil ( $json->totalItems / $per_page ), $json->partOf ); // phpcs:ignore - if ( $page && ( ( \ceil ( $json->totalItems / 20 ) ) > $page ) ) { // phpcs:ignore + if ( $page && ( ( \ceil ( $json->totalItems / $per_page ) ) > $page ) ) { // phpcs:ignore $json->next = \add_query_arg( 'page', $page + 1, $json->partOf ); // phpcs:ignore } @@ -90,10 +94,13 @@ class Followers { // phpcs:ignore $json->orderedItems = array_map( - function( $item ) { + function( $item ) use ( $context ) { + if ( 'full' === $context ) { + return $item->to_array(); + } return $item->get_url(); }, - Follower_Collection::get_followers( $user->get__id(), 20, $page ) + $data['followers'] ); $response = new WP_REST_Response( $json, 200 ); @@ -115,11 +122,28 @@ class Followers { 'default' => 1, ); + $params['per_page'] = array( + 'type' => 'integer', + 'default' => 20, + ); + + $params['order'] = array( + 'type' => 'string', + 'default' => 'desc', + 'enum' => array( 'asc', 'desc' ), + ); + $params['user_id'] = array( 'required' => true, 'type' => 'string', ); + $params['context'] = array( + 'type' => 'string', + 'default' => 'simple', + 'enum' => array( 'simple', 'full' ), + ); + return $params; } } diff --git a/package.json b/package.json index 741ae18..17006ea 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,23 @@ "name": "Matthias Pfefferle", "web": "https://notiz.blog" }, + "scripts": { + "dev": "wp-scripts start", + "build": "wp-scripts build" + }, "license": "MIT", "bugs": { "url": "https://github.com/pfefferle/wordpress-activitypub/issues" }, "homepage": "https://github.com/pfefferle/wordpress-activitypub#readme", "devDependencies": { + "@wordpress/blocks": "^12.11.0", + "@wordpress/components": "^25.0.0", + "@wordpress/data": "^9.4.0", + "@wordpress/dom-ready": "^3.36.0", + "@wordpress/element": "^5.11.0", + "@wordpress/scripts": "^26.5.0", + "classnames": "^2.3.2", "grunt": "^1.1.0", "grunt-checktextdomain": "^1.0.1", "grunt-wp-i18n": "^1.0.3", diff --git a/src/followers/block.json b/src/followers/block.json new file mode 100644 index 0000000..564032f --- /dev/null +++ b/src/followers/block.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "name": "activitypub/followers", + "apiVersion": 3, + "version": "1.0.0", + "title": "Fediverse Followers", + "category": "widgets", + "description": "Display your followers from the Fediverse on your website.", + "textdomain": "activitypub", + "icon": "groups", + "supports": { + "html": false + }, + "attributes": { + "title": { + "type": "string", + "default": "Fediverse Followers" + }, + "selectedUser": { + "type": "string", + "default": "site" + }, + "per_page": { + "type": "number", + "default": 10 + }, + "order": { + "type": "string", + "default": "desc", + "enum": [ "asc", "desc" ] + } + }, + "styles": [ + { "name": "default", "label": "No Lines", "isDefault": true }, + { "name": "with-lines", "label": "Lines" }, + { "name": "compact", "label": "Compact" } + ], + "editorScript": "file:./index.js", + "viewScript": "file:./view.js", + "style": ["file:./style-view.css","wp-block-query-pagination"] +} \ No newline at end of file diff --git a/src/followers/edit.js b/src/followers/edit.js new file mode 100644 index 0000000..19b161f --- /dev/null +++ b/src/followers/edit.js @@ -0,0 +1,68 @@ +import { SelectControl, RangeControl, PanelBody } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useMemo, useState } from '@wordpress/element'; +import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { Followers } from './followers'; + +export default function Edit( { attributes, setAttributes } ) { + const { order, per_page, selectedUser, className } = attributes; + const blockProps = useBlockProps(); + const [ page, setPage ] = useState( 1 ); + const orderOptions = [ + { label: __( 'New to old', 'activitypub' ), value: 'desc' }, + { label: __( 'Old to new', 'activitypub' ), value: 'asc' }, + ]; + const users = useSelect( ( select ) => select( 'core' ).getUsers( { who: 'authors' } ) ); + const usersOptions = useMemo( () => { + if ( ! users ) { + return []; + } + const withBlogUser =[ { + label: __( 'Whole Site', 'activitypub' ), + value: 'site' + } ]; + return users.reduce( ( acc, user ) => { + acc.push({ + label: user.name, + value: user.id + } ); + return acc; + }, withBlogUser ); + }, [ users ] ); + const setAttributestAndResetPage = ( key ) => { + return ( value ) => { + setPage( 1 ); + setAttributes( { [ key ]: value } ); + }; + } + + return ( +
+ + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/followers/followers.js b/src/followers/followers.js new file mode 100644 index 0000000..b756f8f --- /dev/null +++ b/src/followers/followers.js @@ -0,0 +1,105 @@ +import { useState, useEffect } from 'react'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Pagination } from './pagination'; +import { ExternalLink } from '@wordpress/components'; + +const { namespace } = window._activityPubOptions; + +function getPath( userId, per_page, order, page ) { + const path = `/${ namespace }/users/${ userId }/followers`; + const args = { + per_page, + order, + page, + context: 'full' + }; + return addQueryArgs( path, args ); +} + +function usePage() { + const [ page, setPage ] = useState( 1 ); + return [ page, setPage ]; +} + +export function Followers( { + selectedUser, + per_page, + order, + title, + page: passedPage, + setPage: passedSetPage, + className = '' +} ) { + const userId = selectedUser === 'site' ? 0 : selectedUser; + const [ followers, setFollowers ] = useState( [] ); + const [ pages, setPages ] = useState( 0 ); + const [ total, setTotal ] = useState( 0 ); + const [ localPage, setLocalPage ] = usePage(); + const page = passedPage || localPage; + const setPage = passedSetPage || setLocalPage; + const prevLabel = createInterpolateElement( + /* translators: arrow for previous followers link */ + __( ' Less', 'activitypub' ), + { + span: