diff --git a/activitypub.php b/activitypub.php index 984cd62076..357f298569 100644 --- a/activitypub.php +++ b/activitypub.php @@ -52,6 +52,7 @@ function rest_init() { ( new Rest\Actors_Controller() )->register_routes(); ( new Rest\Actors_Inbox_Controller() )->register_routes(); ( new Rest\Admin\Actions_Controller() )->register_routes(); + ( new Rest\Admin\Statistics_Controller() )->register_routes(); ( new Rest\Application_Controller() )->register_routes(); ( new Rest\Collections_Controller() )->register_routes(); ( new Rest\Comments_Controller() )->register_routes(); @@ -140,6 +141,7 @@ function plugin_admin_init() { \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Health_Check', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings_Fields', 'init' ) ); + \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Dashboard', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Welcome_Fields', 'init' ) ); diff --git a/build/dashboard-stats/block.json b/build/dashboard-stats/block.json new file mode 100644 index 0000000000..6c55345671 --- /dev/null +++ b/build/dashboard-stats/block.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "activitypub/dashboard-stats", + "title": "ActivityPub Dashboard Stats", + "category": "widgets", + "description": "ActivityPub statistics dashboard widget", + "textdomain": "activitypub", + "editorScript": "file:./index.js" +} \ No newline at end of file diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php new file mode 100644 index 0000000000..4fbbdc7147 --- /dev/null +++ b/build/dashboard-stats/index.asset.php @@ -0,0 +1 @@ + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '7fc94612f94e9a40bbb8'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js new file mode 100644 index 0000000000..265974de1f --- /dev/null +++ b/build/dashboard-stats/index.js @@ -0,0 +1,5 @@ +(()=>{"use strict";var e,t={2320(e,t,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const r=window.wp.data,o=window.wp.coreData,l=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:a,commentTypes:s,canUseUserActor:i,canUseBlogActor:n}){if(!e)return null;const r=[];return i&&t?.followers&&r.push({key:"followers-user",label:(0,c.__)("New Followers","activitypub"),value:t.followers.current??0,change:t.followers.change??0}),n&&a?.followers&&r.push({key:"followers-blog",label:(0,c.__)("New Followers (Blog)","activitypub"),value:a.followers.current??0,change:a.followers.change??0}),r.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:e.posts?.current??0,change:e.posts?.change??0}),s&&Object.entries(s).forEach(([t,a])=>{const s=e[t];s&&"object"==typeof s&&"current"in s&&r.push({key:t,label:a.label,value:s.current??0,change:s.change??0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:r.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),a=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:a}):(0,p.jsx)("span",{children:a}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=function(e){let t=5381;for(let a=0;ae.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),o=e.map((t,a)=>40+a/(e.length-1||1)*540),l=e.map((e,t)=>({x:o[t],y:170-(e.engagement||0)/r*i,month:e})),u=l.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=u+` L ${l[l.length-1].x} 170`+` L ${l[0].x} 170 Z`,v=t=>e.map((e,a)=>{const s=e[`${t}_count`]||0,n=o[a],l=170-s/r*i;return 0===a?`M ${n} ${l}`:`L ${n} ${l}`}).join(" "),m=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],b=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return t&&Object.entries(t).forEach(([e,t])=>{b.push({key:e,label:t.label,color:d(e)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",role:"img","aria-labelledby":"activitypub-chart-title",children:[(0,p.jsx)("title",{id:"activitypub-chart-title",children:(0,c.__)("Line chart showing engagement trends over the past 12 months","activitypub")}),(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-e),x2:580,y2:s+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map(e=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:d(e),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),l.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:a},t)),l.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:m[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:s+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:b.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function m({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: supporter name */ /* translators: %s: supporter name */ +(0,c.__)("%s (opens in a new tab)","activitypub"),e.name),children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function b({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>{const t=e.title||(0,c.__)("(no title)","activitypub");return(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: post title */ /* translators: %s: post title */ +(0,c.__)("%s (opens in a new tab)","activitypub"),t),children:t}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id)})})]}):null}const y="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:a,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>({currentUser:e(o.store).getCurrentUser(),actorMode:e(o.store).getEntityRecord("root","site")?.activitypub_actor_mode??y,hasUserCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(o.store).isResolving("getCurrentUser",[])}),[]),d=("actor"===t||t===y)&&a&&!!e?.id,g=("blog"===t||t===y)&&i,[x,_]=(0,s.useState)(null),[f,j]=(0,s.useState)(null),[w,k]=(0,s.useState)(null),[N,O]=(0,s.useState)(!0);return(0,s.useEffect)(()=>{if(h)return;O(!0);const t=g?n()({path:"/activitypub/1.0/admin/stats/0"}).catch(()=>null):Promise.resolve(null),a=d&&e?.id?n()({path:`/activitypub/1.0/admin/stats/${e.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([t,a]).then(([e,t])=>{_(e??t),k(e),j(t)}).finally(()=>O(!1))},[h,d,g,e?.id]),h||N?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(l.Spinner,{})})}):x?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:x.comparison,userComparison:f?.comparison??null,blogComparison:w?.comparison??null,commentTypes:x.comment_types,canUseUserActor:d,canUseBlogActor:g}),(0,p.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,p.jsx)(m,{multiplicator:x.stats?.top_multiplicator}),(0,p.jsx)(b,{posts:x.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,s.createRoot)(t).render((0,p.jsx)(g,{}))}}}},a={};function s(e){var i=a[e];if(void 0!==i)return i.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,s),n.exports}s.m=t,e=[],s.O=(t,a,i,n)=>{if(!a){var r=1/0;for(p=0;p=n)&&Object.keys(s.O).every(e=>s.O[e](a[l]))?a.splice(l--,1):(o=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[a,i,n]},s.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return s.d(t,{a:t}),t},s.d=(e,t)=>{for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};s.O.j=t=>0===e[t];var t=(t,a)=>{var i,n,[r,o,l]=a,c=0;if(r.some(t=>0!==e[t])){for(i in o)s.o(o,i)&&(s.m[i]=o[i]);if(l)var p=l(s)}for(t&&t(a);cs(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css new file mode 100644 index 0000000000..7bac4397ba --- /dev/null +++ b/build/dashboard-stats/style-index-rtl.css @@ -0,0 +1 @@ +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css new file mode 100644 index 0000000000..29258d00d0 --- /dev/null +++ b/build/dashboard-stats/style-index.css @@ -0,0 +1 @@ +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index e55a8f36e0..939bd6dcd4 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -292,6 +292,18 @@ public static function register_user_meta() { ); \add_filter( 'get_user_option_activitypub_mailer_new_mention', array( self::class, 'user_options_default' ) ); + \register_meta( + 'user', + $blog_prefix . 'activitypub_mailer_annual_report', + array( + 'type' => 'integer', + 'description' => 'Send the annual Fediverse Year in Review email.', + 'single' => true, + 'sanitize_callback' => 'absint', + ) + ); + \add_filter( 'get_user_option_activitypub_mailer_annual_report', array( self::class, 'user_options_default' ) ); + \register_meta( 'user', 'activitypub_show_welcome_tab', diff --git a/includes/class-cli.php b/includes/class-cli.php index 34a73565b4..15ce228709 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -30,6 +30,7 @@ class Cli { * - wp activitypub self-destruct [--status] [--yes] * - wp activitypub move * - wp activitypub follow + * - wp activitypub stats */ public static function register() { // Register parent command with version subcommand. @@ -96,5 +97,13 @@ public static function register() { 'shortdesc' => 'Follow a remote ActivityPub user.', ) ); + + \WP_CLI::add_command( + 'activitypub stats', + '\Activitypub\Cli\Stats_Command', + array( + 'shortdesc' => 'Manage ActivityPub statistics (collect or compile).', + ) + ); } } diff --git a/includes/class-mailer.php b/includes/class-mailer.php index ad2ba60d6c..7be76afecc 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -427,6 +427,66 @@ public static function mention( $activity, $user_ids ) { } } + /** + * Send a templated email to a user. + * + * @param int $user_id The user ID (or BLOG_USER_ID for blog actor). + * @param string $subject The email subject. + * @param string $template The template name (without path/extension). + * @param array $args Template arguments. + * @param string $alt_body Optional plain text alternative. Auto-generated from HTML if empty. + * + * @return bool True if email was sent, false otherwise. + */ + public static function send( $user_id, $subject, $template, $args = array(), $alt_body = '' ) { + // Get the recipient email address. + if ( $user_id > Actors::BLOG_USER_ID ) { + $user = \get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + return false; + } + $email = $user->user_email; + } else { + $email = \get_option( 'admin_email' ); + } + + // Load the HTML template. + $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . \sanitize_file_name( $template ) . '.php'; + + /** + * Filter the email template file path. + * + * @param string $template_file The template file path. + * @param string $template The template name. + * @param int $user_id The user ID. + * @param array $args Template arguments. + */ + $template_file = \apply_filters( 'activitypub_email_template', $template_file, $template, $user_id, $args ); + + if ( ! \file_exists( $template_file ) ) { + return false; + } + + \ob_start(); + \load_template( $template_file, false, $args ); + $html_message = \ob_get_clean(); + + // Build plain text alternative from HTML if not provided. + if ( empty( $alt_body ) ) { + $alt_body = \wp_strip_all_tags( $html_message ); + } + $alt_function = static function ( $mailer ) use ( $alt_body ) { + $mailer->{'AltBody'} = $alt_body; + }; + \add_action( 'phpmailer_init', $alt_function ); + + $result = \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) ); + + \remove_action( 'phpmailer_init', $alt_function ); + + return $result; + } + /** * Apply defaults to the actor object. * diff --git a/includes/class-migration.php b/includes/class-migration.php index 1ed946f9a4..4b6eab816d 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -33,6 +33,7 @@ public static function init() { Scheduler::register_async_batch_callback( 'activitypub_create_comment_outbox_items', array( self::class, 'create_comment_outbox_items' ) ); Scheduler::register_async_batch_callback( 'activitypub_migrate_avatar_to_remote_actors', array( self::class, 'migrate_avatar_to_remote_actors' ) ); Scheduler::register_async_batch_callback( 'activitypub_migrate_actor_emoji', array( self::class, 'migrate_actor_emoji' ) ); + Scheduler::register_async_batch_callback( 'activitypub_backfill_statistics', array( Statistics::class, 'backfill_historical_stats' ) ); } /** @@ -218,6 +219,8 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, '7.9.0', '<' ) ) { \wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' ); + // Backfill historical statistics data (delay to avoid load immediately after upgrade). + \wp_schedule_single_event( \time() + HOUR_IN_SECONDS, 'activitypub_backfill_statistics' ); } // Ensure all required cron schedules are registered. diff --git a/includes/class-options.php b/includes/class-options.php index a89f667863..9a47641e63 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -414,6 +414,16 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub_blog', + 'activitypub_mailer_annual_report', + array( + 'type' => 'integer', + 'description' => 'Send the annual Fediverse Year in Review email.', + 'default' => 1, + ) + ); + \register_setting( 'activitypub_blog', 'activitypub_blog_user_also_known_as', diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index d42e8c8ecb..8a69be646e 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -18,6 +18,7 @@ use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; use Activitypub\Scheduler\Post; +use Activitypub\Scheduler\Statistics; /** * Scheduler class. @@ -68,6 +69,9 @@ public static function get_retry_delay() { public static function init() { self::register_schedulers(); + // Custom cron schedules. + \add_filter( 'cron_schedules', array( self::class, 'add_cron_schedules' ) ); + // Follower Cleanups. \add_action( 'activitypub_update_remote_actors', array( self::class, 'update_remote_actors' ) ); \add_action( 'activitypub_cleanup_remote_actors', array( self::class, 'cleanup_remote_actors' ) ); @@ -97,6 +101,7 @@ public static function register_schedulers() { Actor::init(); Collection_Sync::init(); Comment::init(); + Statistics::init(); /** * Register additional schedulers. @@ -106,6 +111,27 @@ public static function register_schedulers() { \do_action( 'activitypub_register_schedulers' ); } + /** + * Add custom cron schedules. + * + * @param array $schedules Existing cron schedules. + * + * @return array Modified cron schedules. + */ + public static function add_cron_schedules( $schedules ) { + $schedules['monthly'] = array( + 'interval' => 30 * DAY_IN_SECONDS, + 'display' => \__( 'Once Monthly', 'activitypub' ), + ); + + $schedules['yearly'] = array( + 'interval' => 365 * DAY_IN_SECONDS, + 'display' => \__( 'Once Yearly', 'activitypub' ), + ); + + return $schedules; + } + /** * Register a batch callback for async processing. * @@ -137,6 +163,19 @@ public static function register_schedules() { \wp_schedule_event( time(), $recurrence, $hook ); } } + + // Schedule monthly stats collection for the 1st of each month. + if ( ! \wp_next_scheduled( 'activitypub_collect_monthly_stats' ) ) { + // Calculate next 1st of month at 2:00 AM. + $next_first = self::get_next_first_of_month(); + \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' ); + } + + // Schedule annual stats compilation for December 1st (wrapped notification). + if ( ! \wp_next_scheduled( 'activitypub_compile_annual_stats' ) ) { + $next_december = self::get_next_december_first(); + \wp_schedule_event( $next_december, 'yearly', 'activitypub_compile_annual_stats' ); + } } /** @@ -148,6 +187,42 @@ public static function deregister_schedules() { foreach ( array_keys( self::SCHEDULES ) as $hook ) { \wp_unschedule_hook( $hook ); } + + // Statistics schedules. + \wp_unschedule_hook( 'activitypub_collect_monthly_stats' ); + \wp_unschedule_hook( 'activitypub_compile_annual_stats' ); + } + + /** + * Get the next 1st of month timestamp. + * + * @return int Unix timestamp of next 1st of month at 2:00 AM. + */ + private static function get_next_first_of_month() { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $next_month = \strtotime( 'first day of next month 02:00:00', $now ); + + return $next_month; + } + + /** + * Get the next December 1st timestamp for wrapped notification. + * + * @return int Unix timestamp of next December 1st at 3:00 AM. + */ + private static function get_next_december_first() { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $year = (int) \gmdate( 'Y', $now ); + + // Get December 1st 3:00 AM for this year. + $this_year_dec_first = \strtotime( sprintf( '%d-12-01 03:00:00', $year ) ); + + // If we're already past this year's December 1st, schedule for next year. + if ( $now >= $this_year_dec_first ) { + return \strtotime( sprintf( '%d-12-01 03:00:00', $year + 1 ) ); + } + + return $this_year_dec_first; } /** diff --git a/includes/class-statistics.php b/includes/class-statistics.php new file mode 100644 index 0000000000..ba504e7135 --- /dev/null +++ b/includes/class-statistics.php @@ -0,0 +1,939 @@ + self::count_federated_posts_in_range( $user_id, $start, $end ), + 'followers_count' => $followers_count, + 'followers_total' => self::get_follower_count( $user_id ), + 'top_posts' => self::get_top_posts( $user_id, $start, $end, 5 ), + 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), + 'collected_at' => \gmdate( 'Y-m-d H:i:s' ), + ); + + // Add counts for each comment type dynamically. + foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) { + $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + + self::save_monthly_stats( $user_id, $year, $month, $stats ); + + return $stats; + } + + /** + * Compile annual summary from monthly stats. + * + * @param int $user_id The user ID. + * @param int $year The year. + * + * @return array The annual summary. + */ + public static function compile_annual_summary( $user_id, $year ) { + // Initialize totals dynamically based on registered comment types. + $comment_types = \array_keys( self::get_comment_types_for_stats() ); + $totals = array( 'posts_count' => 0 ); + foreach ( $comment_types as $type ) { + $totals[ $type . '_count' ] = 0; + } + + $most_active_month = null; + $most_active_engagement = 0; + $first_month_stats = null; + $last_month_stats = null; + $all_multiplicators = array(); + + for ( $month = 1; $month <= 12; $month++ ) { + $stats = self::get_monthly_stats( $user_id, $year, $month ); + + if ( ! $stats ) { + continue; + } + + // Track first and last months with data. + if ( ! $first_month_stats ) { + $first_month_stats = $stats; + } + $last_month_stats = $stats; + + // Sum totals dynamically. + $totals['posts_count'] += $stats['posts_count'] ?? 0; + foreach ( $comment_types as $type ) { + $key = $type . '_count'; + $totals[ $key ] += $stats[ $key ] ?? 0; + } + + // Calculate engagement for this month (sum of all comment type counts). + $engagement = 0; + foreach ( $comment_types as $type ) { + $engagement += $stats[ $type . '_count' ] ?? 0; + } + + if ( $engagement > $most_active_engagement ) { + $most_active_engagement = $engagement; + $most_active_month = $month; + } + + // Aggregate multiplicators. + if ( ! empty( $stats['top_multiplicator'] ) && ! empty( $stats['top_multiplicator']['url'] ) ) { + $url = $stats['top_multiplicator']['url']; + if ( ! isset( $all_multiplicators[ $url ] ) ) { + $all_multiplicators[ $url ] = array( + 'name' => $stats['top_multiplicator']['name'], + 'url' => $url, + 'count' => 0, + ); + } + $all_multiplicators[ $url ]['count'] += $stats['top_multiplicator']['count'] ?? 0; + } + } + + // Find top multiplicator for the year. + $top_multiplicator = null; + if ( ! empty( $all_multiplicators ) ) { + \usort( + $all_multiplicators, + function ( $a, $b ) { + return $b['count'] - $a['count']; + } + ); + $top_multiplicator = \reset( $all_multiplicators ); + } + + // Build summary with dynamic comment type counts. + // Calculate followers_start: total at start of first month (total minus gained that month). + // Monthly stats store: followers_count (gained this month), followers_total (total at end of month). + $followers_start = 0; + if ( $first_month_stats ) { + $followers_start = ( $first_month_stats['followers_total'] ?? 0 ) - ( $first_month_stats['followers_count'] ?? 0 ); + } + + $summary = array( + 'posts_count' => $totals['posts_count'], + 'most_active_month' => $most_active_month, + 'followers_start' => $followers_start, + 'followers_end' => $last_month_stats ? ( $last_month_stats['followers_total'] ?? 0 ) : self::get_follower_count( $user_id ), + 'followers_net_change' => 0, + 'top_multiplicator' => $top_multiplicator, + 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ), + ); + + // Add comment type totals dynamically. + foreach ( $comment_types as $type ) { + $summary[ $type . '_count' ] = $totals[ $type . '_count' ]; + } + + $summary['followers_net_change'] = $summary['followers_end'] - $summary['followers_start']; + + self::save_annual_summary( $user_id, $year, $summary ); + + return $summary; + } + + /** + * Count federated posts in a date range. + * + * Counts posts sent via the outbox with activity type 'Create'. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * + * @return int The post count. + */ + public static function count_federated_posts_in_range( $user_id, $start, $end ) { + $meta_query = array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Create', + ), + ); + + // Filter by actor type for user stats. + if ( Actors::BLOG_USER_ID !== $user_id ) { + $meta_query[] = array( + 'key' => '_activitypub_activity_actor', + 'value' => 'user', + ); + } else { + $meta_query[] = array( + 'key' => '_activitypub_activity_actor', + 'value' => 'blog', + ); + } + + $args = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => array( 'publish', 'pending' ), + 'posts_per_page' => 1, + 'fields' => 'ids', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'date_query' => array( + array( + 'after' => $start, + 'before' => $end, + 'inclusive' => true, + ), + ), + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => $meta_query, + ); + + // Filter by post author for user-specific stats. + if ( Actors::BLOG_USER_ID !== $user_id ) { + $args['author'] = $user_id; + } + + $query = new \WP_Query( $args ); + + return $query->found_posts; + } + + /** + * Count engagement (likes, reposts, comments, quotes) in a date range. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * @param string|null $type Optional. The engagement type ('like', 'repost', 'comment', 'quote'). + * + * @return int The engagement count. + */ + public static function count_engagement_in_range( $user_id, $start, $end, $type = null ) { + global $wpdb; + + // Use a subquery to avoid loading all post IDs into memory. + $post_subquery = self::get_post_ids_subquery( $user_id ); + + $type_clause = ''; + if ( $type ) { + $type_clause = $wpdb->prepare( ' AND c.comment_type = %s', $type ); + } else { + // Get all comment types tracked in statistics (includes federated comments via filter). + $comment_types = \array_keys( self::get_comment_types_for_stats() ); + if ( ! empty( $comment_types ) ) { + $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $type_clause = $wpdb->prepare( " AND c.comment_type IN ($placeholders_types)", $comment_types ); + } + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT c.comment_ID) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$post_subquery}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub' + AND c.comment_date_gmt >= %s + AND c.comment_date_gmt <= %s + {$type_clause}", + $start, + $end + ) + ); + // phpcs:enable + + return (int) $count; + } + + /** + * Get top performing posts in a date range. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * @param int $limit Maximum number of posts to return. + * + * @return array Array of top posts with engagement data. + */ + public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { + global $wpdb; + + // Use a subquery with date range to only consider posts published in the period. + $post_subquery = self::get_post_ids_subquery( $user_id, $start, $end ); + + // Get registered comment types dynamically. + $comment_types = Comment::get_comment_type_slugs(); + if ( empty( $comment_types ) ) { + return array(); + } + + $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $type_clause = $wpdb->prepare( "AND c.comment_type IN ({$placeholders_types})", $comment_types ); + + // Get engagement counts per post (only engagement within the date range). + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT c.comment_post_ID as post_id, COUNT(c.comment_ID) as engagement_count + FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$post_subquery}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub' + {$type_clause} + AND c.comment_date_gmt >= %s + AND c.comment_date_gmt <= %s + GROUP BY c.comment_post_ID + ORDER BY engagement_count DESC + LIMIT %d", + $start, + $end, + $limit + ), + ARRAY_A + ); + // phpcs:enable + + $top_posts = array(); + foreach ( $results as $result ) { + $post = \get_post( $result['post_id'] ); + if ( $post ) { + $top_posts[] = array( + 'post_id' => $result['post_id'], + 'title' => \get_the_title( $post ), + 'url' => \get_permalink( $post ), + 'engagement_count' => (int) $result['engagement_count'], + ); + } + } + + return $top_posts; + } + + /** + * Get the top multiplicator (actor who boosted content the most) in a date range. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * + * @return array|null Actor data or null if none found. + */ + public static function get_top_multiplicator( $user_id, $start, $end ) { + global $wpdb; + + // Use a subquery to avoid loading all post IDs into memory. + $post_subquery = self::get_post_ids_subquery( $user_id ); + + // Get actor who boosted the most. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $result = $wpdb->get_row( + $wpdb->prepare( + "SELECT c.comment_author as name, c.comment_author_url as url, COUNT(c.comment_ID) as boost_count + FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$post_subquery}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub' + AND c.comment_type = 'repost' + AND c.comment_date_gmt >= %s + AND c.comment_date_gmt <= %s + GROUP BY c.comment_author_url + ORDER BY boost_count DESC + LIMIT 1", + $start, + $end + ), + ARRAY_A + ); + // phpcs:enable + + if ( ! $result || empty( $result['url'] ) ) { + return null; + } + + return array( + 'name' => $result['name'], + 'url' => $result['url'], + 'count' => (int) $result['boost_count'], + ); + } + + /** + * Get current follower count for a user. + * + * @param int $user_id The user ID. + * + * @return int The follower count. + */ + public static function get_follower_count( $user_id ) { + return Followers::count( $user_id ); + } + + /** + * Get all active user IDs that have ActivityPub enabled. + * + * @return int[] Array of user IDs including BLOG_USER_ID if enabled. + */ + public static function get_active_user_ids() { + return Actors::get_all_ids(); + } + + /** + * Get statistics for the current period. + * + * Always queries live data for the current period to include recent engagement. + * + * @param int $user_id The user ID. + * @param string $period The period ('month', 'year', 'all'). + * + * @return array The statistics. + */ + public static function get_current_stats( $user_id, $period = 'month' ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + + switch ( $period ) { + case 'year': + $start = \gmdate( 'Y-01-01 00:00:00', $now ); + $end = \gmdate( 'Y-12-31 23:59:59', $now ); + break; + + case 'all': + $start = '1970-01-01 00:00:00'; + $end = \gmdate( 'Y-m-d 23:59:59', $now ); + break; + + case 'month': + default: + $start = \gmdate( 'Y-m-01 00:00:00', $now ); + $end = \gmdate( 'Y-m-t 23:59:59', $now ); + break; + } + + $stats = array( + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'followers_total' => self::get_follower_count( $user_id ), + 'top_posts' => self::get_top_posts( $user_id, $start, $end, 3 ), + 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), + 'period' => $period, + 'start' => $start, + 'end' => $end, + ); + + // Add counts for each comment type dynamically. + foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) { + $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + + return $stats; + } + + /** + * Get rolling monthly breakdown (last X months). + * + * Returns stats for the last X months, crossing year boundaries as needed. + * + * @param int $user_id The user ID. + * @param int $num_months Optional. Number of months to return. Defaults to 12. + * + * @return array Array of monthly stats ordered chronologically. + */ + public static function get_rolling_monthly_breakdown( $user_id, $num_months = 12 ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $months = array(); + $comment_types = \array_keys( self::get_comment_types_for_stats() ); + + // Start from (num_months - 1) months ago and go to current month. + for ( $i = $num_months - 1; $i >= 0; $i-- ) { + $timestamp = \strtotime( "-{$i} months", $now ); + $year = (int) \gmdate( 'Y', $timestamp ); + $month = (int) \gmdate( 'n', $timestamp ); + + $month_data = self::get_month_data( $user_id, $year, $month, $comment_types ); + $month_data['year'] = $year; + $month_data['month'] = $month; + + $months[] = $month_data; + } + + return $months; + } + + /** + * Get data for a single month. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * @param array $comment_types Array of comment type slugs. + * + * @return array Month data with posts_count, engagement, and type counts. + */ + private static function get_month_data( $user_id, $year, $month, $comment_types ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $current_year = (int) \gmdate( 'Y', $now ); + $current_month = (int) \gmdate( 'n', $now ); + + // Always query live for the current month to include recent engagement. + $is_current_month = ( $year === $current_year && $month === $current_month ); + + // Check for stored monthly stats first (but not for current month). + $stored_stats = $is_current_month ? false : self::get_monthly_stats( $user_id, $year, $month ); + + if ( $stored_stats ) { + // Use stored data. + $engagement = 0; + foreach ( $comment_types as $type ) { + $engagement += $stored_stats[ $type . '_count' ] ?? 0; + } + + $month_data = array( + 'month' => $month, + 'posts_count' => $stored_stats['posts_count'] ?? 0, + 'engagement' => $engagement, + ); + + // Add counts for each comment type from stored stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = $stored_stats[ $type . '_count' ] ?? 0; + } + } else { + // Query live data. + $last_day = (int) \gmdate( 't', \gmmktime( 0, 0, 0, $month, 1, $year ) ); + $start = \sprintf( '%d-%02d-01 00:00:00', $year, $month ); + $end = \sprintf( '%d-%02d-%02d 23:59:59', $year, $month, $last_day ); + + $engagement = self::count_engagement_in_range( $user_id, $start, $end ); + + $month_data = array( + 'month' => $month, + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'engagement' => $engagement, + ); + + // Add counts for each comment type tracked in stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + } + + return $month_data; + } + + /** + * Get period-over-period comparison (current month vs previous month). + * + * Always queries live data for current month, uses stored stats for previous month. + * + * @param int $user_id The user ID. + * + * @return array Comparison data with current values and changes from previous month. + */ + public static function get_period_comparison( $user_id ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + + // Current month date range. + $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); + + // Previous month (handles year boundary). + $prev_timestamp = \strtotime( '-1 month', $now ); + $prev_year = (int) \gmdate( 'Y', $prev_timestamp ); + $prev_month = (int) \gmdate( 'n', $prev_timestamp ); + $prev_start = \gmdate( 'Y-m-01 00:00:00', $prev_timestamp ); + $prev_end = \gmdate( 'Y-m-t 23:59:59', $prev_timestamp ); + + // Check for stored stats (only for previous month - current month is always live). + $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); + + // Always query live for current month to include recent engagement. + $current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end ); + $current_followers = Followers::count_in_range( $user_id, $current_start, $current_end ); + + // Get previous month data (from stored stats or live query). + if ( $prev_stats ) { + $prev_posts = $prev_stats['posts_count'] ?? 0; + $prev_followers = $prev_stats['followers_count'] ?? 0; + } else { + $prev_posts = self::count_federated_posts_in_range( $user_id, $prev_start, $prev_end ); + $prev_followers = Followers::count_in_range( $user_id, $prev_start, $prev_end ); + } + + $comparison = array( + 'posts' => array( + 'current' => $current_posts, + 'change' => $current_posts - $prev_posts, + ), + 'followers' => array( + 'current' => $current_followers, + 'change' => $current_followers - $prev_followers, + ), + ); + + // Add comparison for each comment type tracked in statistics (includes federated comments). + $comment_types = \array_keys( self::get_comment_types_for_stats() ); + foreach ( $comment_types as $type ) { + // Always query live for current month. + $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type ); + + // Use stored stats for previous month if available. + if ( $prev_stats ) { + $prev_count = $prev_stats[ $type . '_count' ] ?? 0; + } else { + $prev_count = self::count_engagement_in_range( $user_id, $prev_start, $prev_end, $type ); + } + + $comparison[ $type ] = array( + 'current' => $current_count, + 'change' => $current_count - $prev_count, + ); + } + + return $comparison; + } + + /** + * Get comment types to track in statistics. + * + * By default includes all registered ActivityPub comment types. + * Use the 'activitypub_stats_comment_types' filter to add additional types. + * + * @return array Array of comment type data with slug, label, and singular. + */ + public static function get_comment_types_for_stats() { + $comment_types = Comment::get_comment_types(); + $result = array(); + + foreach ( $comment_types as $slug => $type ) { + $result[ $slug ] = array( + 'slug' => $slug, + 'label' => $type['label'] ?? \ucfirst( $slug ), + 'singular' => $type['singular'] ?? \ucfirst( $slug ), + ); + } + + // Add federated comments (replies) which use the standard 'comment' type. + if ( ! isset( $result['comment'] ) ) { + $result['comment'] = array( + 'slug' => 'comment', + 'label' => \__( 'Comments', 'activitypub' ), + 'singular' => \__( 'Comment', 'activitypub' ), + ); + } + + /** + * Filter the comment types tracked in statistics. + * + * Allows adding additional comment types to be tracked + * in the statistics dashboard. + * + * @param array $result Array of comment type data with slug, label, and singular. + */ + return \apply_filters( 'activitypub_stats_comment_types', $result ); + } + + /** + * Backfill historical statistics for all active users. + * + * This method processes statistics in batches to avoid timeouts. + * It only collects stats for completed months (not the current month). + * + * @param int $batch_size Optional. Number of months to process per batch. Default 12. + * @param int $user_index Optional. The current user index being processed. Default 0. + * @param int $year Optional. The year being processed. Default 0 (will determine earliest year). + * @param int $month Optional. The month being processed. Default 1. + * + * @return array|null Array with batch info if more processing needed, null if complete. + */ + public static function backfill_historical_stats( $batch_size = 12, $user_index = 0, $year = 0, $month = 1 ) { + $user_ids = self::get_active_user_ids(); + + if ( empty( $user_ids ) || $user_index >= \count( $user_ids ) ) { + return null; // All done. + } + + $user_id = $user_ids[ $user_index ]; + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $current_year = (int) \gmdate( 'Y', $now ); + $current_month = (int) \gmdate( 'n', $now ); + + // Determine the earliest year with data if not set. + if ( 0 === $year ) { + $year = self::get_earliest_data_year( $user_id ); + if ( ! $year ) { + // No data for this user, move to next user. + return array( + 'batch_size' => $batch_size, + 'user_index' => $user_index + 1, + 'year' => 0, + 'month' => 1, + ); + } + } + + $months_processed = 0; + + // Process months for this user. + while ( $months_processed < $batch_size ) { + // Skip the current month - it's still in progress and should always be queried live. + // Only process completed months (before the current month). + if ( $year > $current_year || ( $year === $current_year && $month >= $current_month ) ) { + // Move to next user. + return array( + 'batch_size' => $batch_size, + 'user_index' => $user_index + 1, + 'year' => 0, + 'month' => 1, + ); + } + + // Check if stats already exist for this month. + $existing = self::get_monthly_stats( $user_id, $year, $month ); + if ( ! $existing ) { + // Collect stats for this month. + self::collect_monthly_stats( $user_id, $year, $month ); + } + + ++$months_processed; + ++$month; + + // Move to next year if needed. + if ( $month > 12 ) { + $month = 1; + ++$year; + } + } + + // More months to process for this user. + return array( + 'batch_size' => $batch_size, + 'user_index' => $user_index, + 'year' => $year, + 'month' => $month, + ); + } + + /** + * Get a prepared SQL subquery that returns post IDs for a user. + * + * This avoids loading all post IDs into PHP memory by using a SQL subquery + * that can be embedded in other queries via IN (...). + * + * @param int $user_id The user ID. + * @param string|null $start Optional start date (Y-m-d H:i:s). + * @param string|null $end Optional end date (Y-m-d H:i:s). + * + * @return string Prepared SQL subquery string. + */ + private static function get_post_ids_subquery( $user_id, $start = null, $end = null ) { + global $wpdb; + + $post_types = (array) \get_option( 'activitypub_support_post_types', array( 'post' ) ); + $type_placeholders = \implode( ', ', \array_fill( 0, \count( $post_types ), '%s' ) ); + $params = $post_types; + + $author_clause = ''; + if ( Actors::BLOG_USER_ID !== $user_id ) { + $author_clause = ' AND post_author = %d'; + $params[] = $user_id; + } + + $date_clause = ''; + if ( $start && $end ) { + $date_clause = ' AND post_date_gmt >= %s AND post_date_gmt <= %s'; + $params[] = $start; + $params[] = $end; + } + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}){$author_clause}{$date_clause}", + $params + ); + // phpcs:enable + } + + /** + * Get the earliest year that has ActivityPub data for a user. + * + * @param int $user_id The user ID. + * + * @return int|null The earliest year with data, or null if no data. + */ + private static function get_earliest_data_year( $user_id ) { + global $wpdb; + + // Use a subquery to avoid loading all post IDs into memory. + $post_subquery = self::get_post_ids_subquery( $user_id ); + + // Find earliest comment with ActivityPub protocol. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $earliest_date = $wpdb->get_var( + "SELECT MIN(c.comment_date_gmt) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$post_subquery}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub'" + ); + // phpcs:enable + + if ( ! $earliest_date ) { + // No ActivityPub data, check outbox instead. + $outbox_args = array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'ASC', + 'fields' => 'ids', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Create', + ), + ), + ); + + if ( Actors::BLOG_USER_ID !== $user_id ) { + $outbox_args['author'] = $user_id; + } + + $earliest_outbox = \get_posts( $outbox_args ); + + if ( empty( $earliest_outbox ) ) { + return null; + } + + $earliest_post = \get_post( $earliest_outbox[0] ); + $earliest_date = $earliest_post->post_date_gmt; + } + + return (int) \gmdate( 'Y', \strtotime( $earliest_date ) ); + } +} diff --git a/includes/cli/class-stats-command.php b/includes/cli/class-stats-command.php new file mode 100644 index 0000000000..337fc2e53b --- /dev/null +++ b/includes/cli/class-stats-command.php @@ -0,0 +1,172 @@ +] + * : The user ID to collect stats for. Omit to collect for all active users. + * + * [--year=] + * : The year to collect stats for. Defaults to current year. + * + * [--month=] + * : The month to collect stats for (1-12). Defaults to current month. + * + * [--force] + * : Force recollection even if stats already exist. + * + * ## EXAMPLES + * + * # Collect real stats for current month + * $ wp activitypub stats collect + * + * # Collect stats for a specific month + * $ wp activitypub stats collect --year=2024 --month=6 + * + * # Force recollect stats for a specific user + * $ wp activitypub stats collect --user_id=1 --force + * + * @subcommand collect + * + * @param array $args The positional arguments (unused). + * @param array $assoc_args The associative arguments. + */ + public function collect( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : (int) \gmdate( 'Y' ); + $month = isset( $assoc_args['month'] ) ? (int) $assoc_args['month'] : (int) \gmdate( 'n' ); + $force = isset( $assoc_args['force'] ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + + foreach ( $user_ids as $uid ) { + if ( $force ) { + $option_name = Statistics::get_monthly_option_name( $uid, $year, $month ); + \delete_option( $option_name ); + } + Statistics::collect_monthly_stats( $uid, $year, $month ); + } + + $count = count( $user_ids ); + \WP_CLI::success( "Monthly stats collected for {$count} user(s) ({$year}-{$month})." ); + } + + /** + * Compile annual statistics. + * + * Aggregates monthly statistics into an annual summary including totals, + * averages, and highlights for the year. + * + * ## OPTIONS + * + * [--user_id=] + * : The user ID to compile stats for. Omit to compile for all active users. + * + * [--year=] + * : The year to compile stats for. Defaults to previous year. + * + * ## EXAMPLES + * + * # Compile annual stats for previous year + * $ wp activitypub stats compile + * + * # Compile annual stats for a specific year + * $ wp activitypub stats compile --year=2024 + * + * # Compile for a specific user + * $ wp activitypub stats compile --user_id=1 --year=2024 + * + * @subcommand compile + * + * @param array $args The positional arguments (unused). + * @param array $assoc_args The associative arguments. + */ + public function compile( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : ( (int) \gmdate( 'Y' ) - 1 ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + + foreach ( $user_ids as $uid ) { + Statistics::compile_annual_summary( $uid, $year ); + } + + $count = count( $user_ids ); + \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$year})." ); + } + + /** + * Send the annual report email. + * + * Compiles annual statistics and sends the Fediverse Year in Review + * email for the specified year. + * + * ## OPTIONS + * + * [--user_id=] + * : The user ID to send the email for. Omit to send for all active users. + * + * [--year=] + * : The year to send the report for. Defaults to previous year. + * + * ## EXAMPLES + * + * # Send annual report for previous year + * $ wp activitypub stats send + * + * # Send annual report for a specific year + * $ wp activitypub stats send --year=2025 + * + * # Send for a specific user + * $ wp activitypub stats send --user_id=1 --year=2025 + * + * @subcommand send + * + * @param array $args The positional arguments (unused). + * @param array $assoc_args The associative arguments. + */ + public function send( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : ( (int) \gmdate( 'Y' ) - 1 ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + + $sent = 0; + foreach ( $user_ids as $uid ) { + $summary = Statistics::compile_annual_summary( $uid, $year ); + + if ( empty( $summary ) ) { + \WP_CLI::warning( "No stats found for user {$uid} ({$year}), skipping." ); + continue; + } + + Statistics_Scheduler::send_annual_email( $uid, $year, $summary ); + \WP_CLI::log( "Annual report email sent for user {$uid} ({$year})." ); + ++$sent; + } + + \WP_CLI::success( "Annual report email sent for {$sent} user(s) ({$year})." ); + } +} diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index bcad4a0ab0..d1eee117f3 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -313,6 +313,34 @@ public static function count( $user_id ) { return self::query( $user_id, 1 )['total']; } + /** + * Count followers gained in a date range. + * + * @param int $user_id The ID of the WordPress User. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * + * @return int The number of new followers in the date range. + */ + public static function count_in_range( $user_id, $start, $end ) { + $result = self::query( + $user_id, + 1, // We only need the count. + null, + array( + 'date_query' => array( + array( + 'after' => $start, + 'before' => $end, + 'inclusive' => true, + ), + ), + ) + ); + + return $result['total']; + } + /** * Count the total number of followers. * diff --git a/includes/rest/admin/class-statistics-controller.php b/includes/rest/admin/class-statistics-controller.php new file mode 100644 index 0000000000..45b07ca166 --- /dev/null +++ b/includes/rest/admin/class-statistics-controller.php @@ -0,0 +1,124 @@ +namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'user_id' => array( + 'description' => 'The user ID to get stats for.', + 'type' => 'integer', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Checks if a given request has access to get stats. + * + * @param \WP_REST_Request $request The request object. + * + * @return true|\WP_Error True if the request has access, WP_Error otherwise. + */ + public function get_item_permissions_check( $request ) { + $user_id = (int) $request->get_param( 'user_id' ); + + // Check if user can access stats for this actor. + if ( Actors::BLOG_USER_ID === $user_id ) { + if ( ! \current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'rest_forbidden', + \__( 'You do not have permission to view blog stats.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + } elseif ( \get_current_user_id() !== $user_id ) { + return new \WP_Error( + 'rest_forbidden', + \__( 'You do not have permission to view this user\'s stats.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Retrieves statistics for a user. + * + * @param \WP_REST_Request $request The request object. + * + * @return \WP_REST_Response|\WP_Error Response object or WP_Error object. + */ + public function get_item( $request ) { + $user_id = (int) $request->get_param( 'user_id' ); + $transient_key = 'activitypub_stats_' . $user_id; + + $response = \get_transient( $transient_key ); + + if ( false === $response ) { + $stats = Statistics::get_current_stats( $user_id, 'month' ); + $comparison = Statistics::get_period_comparison( $user_id ); + $monthly_data = Statistics::get_rolling_monthly_breakdown( $user_id ); + $comment_types = Statistics::get_comment_types_for_stats(); + + $response = array( + 'stats' => array( + 'posts_count' => $stats['posts_count'], + 'followers_total' => $stats['followers_total'], + 'top_posts' => $stats['top_posts'], + 'top_multiplicator' => $stats['top_multiplicator'], + ), + 'comparison' => $comparison, + 'monthly' => \array_values( $monthly_data ), + 'comment_types' => $comment_types, + ); + + \set_transient( $transient_key, $response, 15 * MINUTE_IN_SECONDS ); + } + + return \rest_ensure_response( $response ); + } +} diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php new file mode 100644 index 0000000000..187f145064 --- /dev/null +++ b/includes/scheduler/class-statistics.php @@ -0,0 +1,172 @@ + \Activitypub\Collection\Actors::BLOG_USER_ID ) { + if ( ! \get_user_option( 'activitypub_mailer_annual_report', $user_id ) ) { + return; + } + } elseif ( '1' !== \get_option( 'activitypub_mailer_annual_report', '1' ) ) { + return; + } + + // Don't send email if there's no activity. + // Check posts and all registered comment types dynamically. + $has_activity = ! empty( $summary['posts_count'] ); + if ( ! $has_activity ) { + $comment_types = \array_keys( Statistics_Collector::get_comment_types_for_stats() ); + foreach ( $comment_types as $type ) { + if ( ! empty( $summary[ $type . '_count' ] ) ) { + $has_activity = true; + break; + } + } + } + + if ( ! $has_activity ) { + return; + } + + $args = \array_merge( + $summary, + array( + 'year' => $year, + 'user_id' => $user_id, + ) + ); + + // Get month name for most_active_month. + if ( ! empty( $summary['most_active_month'] ) ) { + $args['most_active_month_name'] = \date_i18n( 'F', \strtotime( sprintf( '%d-%02d-01', $year, $summary['most_active_month'] ) ) ); + } + + $subject = \sprintf( + /* translators: 1: Blog name, 2: Year */ + \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ), + \esc_html( \get_option( 'blogname' ) ), + $year + ); + + // Build plain text alternative. + /* translators: %d: Year */ + $alt_body = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year ); + + if ( ! empty( $args['posts_count'] ) ) { + /* translators: %d: Number of posts */ + $alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] ); + } + + if ( ! empty( $args['followers_net_change'] ) ) { + /* translators: %d: Net follower change */ + $alt_body .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] ); + } + + if ( ! empty( $args['most_active_month_name'] ) ) { + /* translators: %s: Month name */ + $alt_body .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $args['most_active_month_name'] ); + } + + Mailer::send( $user_id, $subject, 'annual-wrapped', $args, $alt_body ); + } +} diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 9d6806491e..dbfe330c7c 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -11,7 +11,6 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Extra_Fields; use Activitypub\Comment; -use Activitypub\Model\Blog; use Activitypub\Moderation; use Activitypub\Scheduler\Actor; use Activitypub\Tombstone; @@ -74,8 +73,6 @@ public static function init() { \add_action( 'admin_print_scripts-settings_page_activitypub', array( self::class, 'enqueue_moderation_scripts' ) ); \add_action( 'admin_print_footer_scripts-settings_page_activitypub', array( self::class, 'open_help_tab' ) ); - \add_action( 'wp_dashboard_setup', array( self::class, 'add_dashboard_widgets' ) ); - \add_action( 'wp_ajax_activitypub_moderation_settings', array( self::class, 'ajax_moderation_settings' ) ); \add_action( 'wp_ajax_activitypub_blocklist_subscription', array( self::class, 'ajax_blocklist_subscription' ) ); } @@ -252,6 +249,7 @@ public static function save_user_settings( $user_id ) { 'activitypub_mailer_new_dm', 'activitypub_mailer_new_follower', 'activitypub_mailer_new_mention', + 'activitypub_mailer_annual_report', ); foreach ( $required_user_options as $option ) { @@ -954,78 +952,6 @@ function activitypub_open_help_tab(event) { '; - \wp_widget_rss_output( - array( - 'url' => 'https://activitypub.blog/feed/', - 'items' => 3, - 'show_summary' => 1, - 'show_author' => 0, - 'show_date' => 1, - ) - ); - echo ''; - } - - /** - * Add the ActivityPub Author profile as a Dashboard widget. - */ - public static function profile_dashboard_widget() { - $user = Actors::get_by_id( \get_current_user_id() ); - ?> -

- -

-

-

-

- - - - -

- -

- -

-

-

-

- - - - - - -

-

+

+ +

'; + \wp_widget_rss_output( + array( + 'url' => 'https://activitypub.blog/feed/', + 'items' => 3, + 'show_summary' => 1, + 'show_author' => 0, + 'show_date' => 1, + ) + ); + echo ''; + } + + /** + * Render the ActivityPub Author profile widget. + */ + public static function render_author_profile_widget() { + $user = Actors::get_by_id( \get_current_user_id() ); + ?> +

+ +

+

+

+

+ + + + +

+ +

+ +

+

+

+

+ + + + + + +

+ '; + } +} diff --git a/includes/wp-admin/class-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php index a011651e8f..dac752ce98 100644 --- a/includes/wp-admin/class-user-settings-fields.php +++ b/includes/wp-admin/class-user-settings-fields.php @@ -250,6 +250,12 @@ public static function notifications_callback() {

+

+ +

+ * : The action to perform. + * --- + * options: + * - populate + * - clear + * - collect + * - compile + * --- + * + * [--user_id=] + * : The user ID to operate on. Defaults to blog user (0). + * + * [--year=] + * : The year to collect/compile stats for. Defaults to current year. + * + * [--month=] + * : The month to collect stats for (1-12). Defaults to current month. + * + * [--force] + * : Force recollection even if stats already exist. + * + * ## EXAMPLES + * + * # Populate demo stats for the blog + * $ wp activitypub stats populate + * + * # Populate demo stats for a specific user + * $ wp activitypub stats populate --user_id=1 + * + * # Clear demo stats for the blog + * $ wp activitypub stats clear + * + * # Collect real stats for current month + * $ wp activitypub stats collect + * + * # Collect stats for a specific month (force recollect) + * $ wp activitypub stats collect --year=2024 --month=6 --force + * + * # Compile annual stats + * $ wp activitypub stats compile --year=2024 + * + * @synopsis [--user_id=] [--year=] [--month=] [--force] + * + * @param array $args The positional arguments. + * @param array $assoc_args The associative arguments. + */ + public function stats( $args, $assoc_args = array() ) { + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : null; + $month = isset( $assoc_args['month'] ) ? (int) $assoc_args['month'] : null; + $force = isset( $assoc_args['force'] ); + + switch ( $args[0] ) { + case 'populate': + $target_user = $user_id ?? Actors::BLOG_USER_ID; + $this->populate_demo_stats( $target_user ); + \WP_CLI::success( "Demo statistics populated for user ID: {$target_user}" ); + break; + + case 'clear': + $target_user = $user_id ?? Actors::BLOG_USER_ID; + $this->clear_demo_stats( $target_user ); + \WP_CLI::success( "Demo statistics cleared for user ID: {$target_user}" ); + break; + + case 'collect': + $results = $this->collect_monthly_stats( $user_id, $year, $month, $force ); + $count = count( $results ); + $y = $year ?? gmdate( 'Y' ); + $m = $month ?? gmdate( 'n' ); + \WP_CLI::success( "Monthly stats collected for {$count} user(s) ({$y}-{$m})." ); + break; + + case 'compile': + $results = $this->compile_annual_stats( $user_id, $year ); + $count = count( $results ); + $y = $year ?? ( gmdate( 'Y' ) - 1 ); + \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$y})." ); + break; + + default: + \WP_CLI::error( 'Unknown action. Use "populate", "clear", "collect", or "compile".' ); + } + } + + /** + * Collect monthly statistics. + * + * @param int|null $user_id The user ID or null for all users. + * @param int|null $year The year. + * @param int|null $month The month. + * @param bool $force Force recollection even if stats exist. + * + * @return array Results per user. + */ + private function collect_monthly_stats( $user_id, $year, $month, $force ) { + $year = $year ?? (int) gmdate( 'Y' ); + $month = $month ?? (int) gmdate( 'n' ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + $results = array(); + + foreach ( $user_ids as $uid ) { + if ( $force ) { + $option_name = Statistics::get_monthly_option_name( $uid, $year, $month ); + \delete_option( $option_name ); + } + $results[ $uid ] = Statistics::collect_monthly_stats( $uid, $year, $month ); + } + + return $results; + } + + /** + * Compile annual statistics. + * + * @param int|null $user_id The user ID or null for all users. + * @param int|null $year The year. + * + * @return array Results per user. + */ + private function compile_annual_stats( $user_id, $year ) { + $year = $year ?? ( (int) gmdate( 'Y' ) - 1 ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + $results = array(); + + foreach ( $user_ids as $uid ) { + $results[ $uid ] = Statistics::compile_annual_summary( $uid, $year ); + } + + return $results; + } + + /** + * Populate demo statistics data for testing. + * + * @param int $user_id The user ID to populate data for. + */ + private function populate_demo_stats( $user_id ) { + $current_year = (int) \gmdate( 'Y' ); + $current_month = (int) \gmdate( 'n' ); + + // Get registered comment types dynamically. + $comment_types = Comment::get_comment_type_slugs(); + + // Base values that will grow over time. + $followers_base = 50; + + // Populate monthly stats for the current year. + for ( $month = 1; $month <= $current_month; $month++ ) { + // Create realistic growth patterns. + $growth_factor = $month / 12; + $seasonal_boost = \in_array( $month, array( 3, 9, 10 ), true ) ? 1.3 : 1.0; + + $posts_count = (int) ( \wp_rand( 6, 14 ) * $seasonal_boost ); + + // Followers grow over time. + $followers_gained = (int) ( \wp_rand( 10, 30 ) * ( 1 + $growth_factor * 0.5 ) ); + $followers_lost = \wp_rand( 1, 5 ); + $followers_base += $followers_gained - $followers_lost; + + $stats = array( + 'posts_count' => $posts_count, + 'followers_gained' => $followers_gained, + 'followers_lost' => $followers_lost, + 'followers_total' => $followers_base, + 'top_posts' => array(), + 'top_multiplicator' => array( + 'name' => '@supporter' . $month . '@mastodon.social', + 'url' => 'https://mastodon.social/@supporter' . $month, + 'count' => \wp_rand( 3, 10 ), + ), + 'collected_at' => \gmdate( 'Y-m-d H:i:s', \strtotime( "$current_year-$month-28" ) ), + ); + + // Add counts for each registered comment type dynamically. + foreach ( $comment_types as $type ) { + $stats[ $type . '_count' ] = (int) ( \wp_rand( 5, 30 ) * ( 1 + $growth_factor ) * $seasonal_boost ); + } + + Statistics::save_monthly_stats( $user_id, $current_year, $month, $stats ); + } + } + + /** + * Clear demo statistics data. + * + * @param int $user_id The user ID to clear data for. + */ + private function clear_demo_stats( $user_id ) { + $current_year = (int) \gmdate( 'Y' ); + + for ( $month = 1; $month <= 12; $month++ ) { + $option_name = Statistics::get_monthly_option_name( $user_id, $current_year, $month ); + \delete_option( $option_name ); + } + + $annual_option = Statistics::get_annual_option_name( $user_id, $current_year ); + \delete_option( $annual_option ); + } } diff --git a/src/dashboard-stats/block.json b/src/dashboard-stats/block.json new file mode 100644 index 0000000000..ec8ffbfe14 --- /dev/null +++ b/src/dashboard-stats/block.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "activitypub/dashboard-stats", + "title": "ActivityPub Dashboard Stats", + "category": "widgets", + "description": "ActivityPub statistics dashboard widget", + "textdomain": "activitypub", + "editorScript": "file:./index.js" +} diff --git a/src/dashboard-stats/components/line-chart/index.tsx b/src/dashboard-stats/components/line-chart/index.tsx new file mode 100644 index 0000000000..d9d41be26b --- /dev/null +++ b/src/dashboard-stats/components/line-chart/index.tsx @@ -0,0 +1,247 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { MonthData, CommentType } from '../../types'; + +interface Props { + monthly: MonthData[] | null; + commentTypes: Record< string, CommentType > | null; +} + +// WordPress default color palette (always available). +// @see https://developer.wordpress.org/themes/global-settings-and-styles/settings/color/ +const WP_DEFAULT_COLORS = [ + { slug: 'vivid-red', hex: '#cf2e2e' }, + { slug: 'vivid-green-cyan', hex: '#00d084' }, + { slug: 'luminous-vivid-amber', hex: '#fcb900' }, + { slug: 'vivid-purple', hex: '#9b51e0' }, + { slug: 'vivid-cyan-blue', hex: '#0693e3' }, + { slug: 'luminous-vivid-orange', hex: '#ff6900' }, +]; + +/** + * Simple string hash function for deterministic color assignment. + * Uses djb2 algorithm for consistent results across page loads. + * @param str The string to hash. + */ +function hashString( str: string ): number { + let hash = 5381; + for ( let i = 0; i < str.length; i++ ) { + // eslint-disable-next-line no-bitwise -- djb2 hash algorithm requires XOR. + hash = ( hash * 33 ) ^ str.charCodeAt( i ); + } + return Math.abs( hash ); +} + +/** + * Get CSS variable with fallback to hex value. + * Uses CSS var() with fallback for best compatibility. + * Color assignment is deterministic based on type slug hash. + * @param typeSlug The comment type slug for deterministic color. + */ +function getColorForType( typeSlug: string ): string { + const index = hashString( typeSlug ) % WP_DEFAULT_COLORS.length; + const color = WP_DEFAULT_COLORS[ index ]; + return `var(--wp--preset--color--${ color.slug }, ${ color.hex })`; +} + +/** + * Get the engagement color (primary/accent, uses vivid-cyan-blue). + */ +function getEngagementColor(): string { + return 'var(--wp--preset--color--vivid-cyan-blue, #0693e3)'; +} + +/** + * Line Chart Component. + * + * Renders an SVG line chart for monthly engagement data. + * + * @param {Props} props Component props. + */ +export default function LineChart( { monthly, commentTypes }: Props ): ReactNode { + if ( ! monthly?.length ) { + return null; + } + + // Get colors once at render time. + const engagementColor = getEngagementColor(); + + const width = 600; + const height = 200; + const padding = { top: 20, right: 20, bottom: 30, left: 40 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + // Get engagement type slugs from commentTypes. + const typeKeys = commentTypes ? Object.keys( commentTypes ) : []; + + // Get max value across all engagement types for proper scaling. + const maxEngagement = Math.max( + ...monthly.map( ( m ) => m.engagement || 0 ), + ...typeKeys.flatMap( ( type ) => monthly.map( ( m ) => ( m[ `${ type }_count` ] as number ) || 0 ) ), + 1 + ); + + // Calculate x positions for each month. + const xPositions = monthly.map( ( _, index ) => { + return padding.left + ( index / ( monthly.length - 1 || 1 ) ) * chartWidth; + } ); + + // Calculate points for the total engagement line. + const engagementPoints = monthly.map( ( month, index ) => { + const x = xPositions[ index ]; + const y = padding.top + chartHeight - ( ( month.engagement || 0 ) / maxEngagement ) * chartHeight; + return { x, y, month }; + } ); + + // Create path for the engagement line. + const engagementPath = engagementPoints + .map( ( point, index ) => ( index === 0 ? `M ${ point.x } ${ point.y }` : `L ${ point.x } ${ point.y }` ) ) + .join( ' ' ); + + // Create path for the area fill. + const areaPath = + engagementPath + + ` L ${ engagementPoints[ engagementPoints.length - 1 ].x } ${ padding.top + chartHeight }` + + ` L ${ engagementPoints[ 0 ].x } ${ padding.top + chartHeight } Z`; + + // Helper to create line path for a specific type. + const createLinePath = ( type: string ) => { + return monthly + .map( ( month, index ) => { + const value = ( month[ `${ type }_count` ] as number ) || 0; + const x = xPositions[ index ]; + const y = padding.top + chartHeight - ( value / maxEngagement ) * chartHeight; + return index === 0 ? `M ${ x } ${ y }` : `L ${ x } ${ y }`; + } ) + .join( ' ' ); + }; + + // Month labels. + const monthLabels = [ + __( 'Jan', 'activitypub' ), + __( 'Feb', 'activitypub' ), + __( 'Mar', 'activitypub' ), + __( 'Apr', 'activitypub' ), + __( 'May', 'activitypub' ), + __( 'Jun', 'activitypub' ), + __( 'Jul', 'activitypub' ), + __( 'Aug', 'activitypub' ), + __( 'Sep', 'activitypub' ), + __( 'Oct', 'activitypub' ), + __( 'Nov', 'activitypub' ), + __( 'Dec', 'activitypub' ), + ]; + + // Build legend items from comment types. + const legendItems = [ + { key: 'engagement', label: __( 'Total Engagement', 'activitypub' ), color: engagementColor }, + ]; + + if ( commentTypes ) { + Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => { + legendItems.push( { key: slug, label: type.label, color: getColorForType( slug ) } ); + } ); + } + + return ( +
+

{ __( 'Engagement Over Time', 'activitypub' ) }

+
+ + + { __( 'Line chart showing engagement trends over the past 12 months', 'activitypub' ) } + + + + + + + + + { /* Grid lines */ } + { [ 0, 0.25, 0.5, 0.75, 1 ].map( ( ratio ) => ( + + ) ) } + + { /* Area fill for total engagement */ } + + + { /* Lines for each engagement type */ } + { typeKeys.map( ( type ) => ( + + ) ) } + + { /* Total engagement line */ } + + + { /* Data points for total engagement */ } + { engagementPoints.map( ( point, index ) => ( + + ) ) } + + { /* X-axis labels */ } + { engagementPoints.map( ( point, index ) => ( + + { monthLabels[ point.month.month - 1 ] } + + ) ) } + + { /* Y-axis labels */ } + { [ 0, 0.5, 1 ].map( ( ratio ) => ( + + { Math.round( maxEngagement * ratio ) } + + ) ) } + + + { /* Legend */ } +
+ { legendItems.map( ( item ) => ( +
+ + { item.label } +
+ ) ) } +
+
+
+ ); +} diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx new file mode 100644 index 0000000000..7425d29547 --- /dev/null +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { Comparison, CommentType } from '../../types'; + +interface Props { + comparison: Comparison | null; + userComparison: Comparison | null; + blogComparison: Comparison | null; + commentTypes: Record< string, CommentType > | null; + canUseUserActor: boolean; + canUseBlogActor: boolean; +} + +/** + * Get the admin URL for a stat type. + * + * @param {string} type The stat type (followers, posts, etc.). + * @return {string|null} The admin URL or null if no link. + */ +function getStatUrl( type: string ): string | null { + switch ( type ) { + case 'followers': + case 'followers-user': + return 'users.php?page=activitypub-followers-list'; + case 'followers-blog': + return 'options-general.php?page=activitypub&tab=followers'; + case 'posts': + return 'edit.php'; + default: + // Likes, reposts, comments filter by comment type. + return `edit-comments.php?comment_type=${ type }`; + } +} + +/** + * Stat Highlights Component. + * + * Displays key statistics with month-over-month comparison. + * Shows follower change and engagement stats for available actors. + * + * @param {Props} props Component props. + */ +export default function StatHighlights( { + comparison, + userComparison, + blogComparison, + commentTypes, + canUseUserActor, + canUseBlogActor, +}: Props ): ReactNode { + if ( ! comparison ) { + return null; + } + + // Build stats array dynamically. + const stats: Array< { key: string; label: string; value: number; change: number } > = []; + + // Add user followers if available (from user-specific stats). + // Note: This shows new followers gained this month, not total followers. + if ( canUseUserActor && userComparison?.followers ) { + stats.push( { + key: 'followers-user', + label: __( 'New Followers', 'activitypub' ), + value: userComparison.followers.current ?? 0, + change: userComparison.followers.change ?? 0, + } ); + } + + // Add blog followers if available (from blog-specific stats). + // Note: This shows new followers gained this month, not total followers. + if ( canUseBlogActor && blogComparison?.followers ) { + stats.push( { + key: 'followers-blog', + label: __( 'New Followers (Blog)', 'activitypub' ), + value: blogComparison.followers.current ?? 0, + change: blogComparison.followers.change ?? 0, + } ); + } + + // Add posts. + stats.push( { + key: 'posts', + label: __( 'Posts', 'activitypub' ), + value: comparison.posts?.current ?? 0, + change: comparison.posts?.change ?? 0, + } ); + + // Add engagement types dynamically from comment types. + if ( commentTypes ) { + Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => { + const comparisonData = comparison[ slug as keyof Comparison ]; + if ( comparisonData && typeof comparisonData === 'object' && 'current' in comparisonData ) { + stats.push( { + key: slug, + label: type.label, + value: comparisonData.current ?? 0, + change: comparisonData.change ?? 0, + } ); + } + } ); + } + + return ( +
+

{ __( 'This month vs. last month', 'activitypub' ) }

+
    + { stats.map( ( stat ) => { + const url = getStatUrl( stat.key ); + const content = ( + <> + { stat.value.toLocaleString() } { stat.label } + + ); + return ( +
  • + { url ? { content } : { content } } + { stat.change !== 0 && ' ' } + { stat.change !== 0 && ( + 0 ? 'positive' : 'negative' }` }> + ({ stat.change > 0 ? '+' : '' } + { stat.change.toLocaleString() }) + + ) } +
  • + ); + } ) } +
+
+ ); +} diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx new file mode 100644 index 0000000000..80f46bc18d --- /dev/null +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import StatHighlights from '../stat-highlights'; +import LineChart from '../line-chart'; +import TopSupporter from '../top-supporter'; +import TopPosts from '../top-posts'; +import type { StatsResponse } from '../../types'; + +// Actor mode constants matching PHP definitions. +const ACTOR_MODE = 'actor'; +const BLOG_MODE = 'blog'; +const ACTOR_AND_BLOG_MODE = 'actor_blog'; + +// Blog user ID constant matching PHP. +const BLOG_USER_ID = 0; + +/** + * Stats Widget Component. + * + * Displays global engagement stats and follower counts for available actors. + */ +export default function StatsWidget(): ReactNode { + const { currentUser, actorMode, hasUserCap, hasBlogCap, isResolving } = useSelect( + ( select ) => ( { + currentUser: select( coreStore ).getCurrentUser(), + actorMode: + ( + select( coreStore ).getEntityRecord( 'root', 'site' ) as + | { activitypub_actor_mode?: string } + | undefined + )?.activitypub_actor_mode ?? ACTOR_AND_BLOG_MODE, + // Check if user has the activitypub capability (can create user extra fields). + hasUserCap: select( coreStore ).canUser( 'create', { + kind: 'postType', + name: 'ap_extrafield', + } ), + // Check if user can manage options (can create blog extra fields). + hasBlogCap: select( coreStore ).canUser( 'create', { + kind: 'postType', + name: 'ap_extrafield_blog', + } ), + isResolving: select( coreStore ).isResolving( 'getCurrentUser', [] ), + } ), + [] + ); + + // User can use their actor if user mode is enabled AND they have the capability. + const userModeEnabled: boolean = actorMode === ACTOR_MODE || actorMode === ACTOR_AND_BLOG_MODE; + const canUseUserActor: boolean = userModeEnabled && hasUserCap && !! currentUser?.id; + + // User can use the blog actor if blog mode is enabled AND they have the capability. + const blogModeEnabled: boolean = actorMode === BLOG_MODE || actorMode === ACTOR_AND_BLOG_MODE; + const canUseBlogActor: boolean = blogModeEnabled && hasBlogCap; + + const [ stats, setStats ] = useState< StatsResponse | null >( null ); + const [ userStats, setUserStats ] = useState< StatsResponse | null >( null ); + const [ blogStats, setBlogStats ] = useState< StatsResponse | null >( null ); + const [ isLoading, setIsLoading ] = useState( true ); + + // Load stats for blog (global engagement) and separate follower stats per actor. + useEffect( () => { + if ( isResolving ) { + return; + } + + setIsLoading( true ); + + // Fetch blog stats (global engagement data) - only if user has blog capability. + const blogStatsPromise = canUseBlogActor + ? apiFetch< StatsResponse >( { + path: `/activitypub/1.0/admin/stats/${ BLOG_USER_ID }`, + } ).catch( () => null ) + : Promise.resolve( null ); + + // Fetch user-specific stats if user actor is available. + const userStatsPromise = + canUseUserActor && currentUser?.id + ? apiFetch< StatsResponse >( { + path: `/activitypub/1.0/admin/stats/${ currentUser.id }`, + } ).catch( () => null ) + : Promise.resolve( null ); + + Promise.all( [ blogStatsPromise, userStatsPromise ] ) + .then( ( [ blogData, userData ] ) => { + // Use blog stats as primary if available, otherwise fall back to user stats. + setStats( blogData ?? userData ); + setBlogStats( blogData ); + setUserStats( userData ); + } ) + .finally( () => setIsLoading( false ) ); + }, [ isResolving, canUseUserActor, canUseBlogActor, currentUser?.id ] ); + + // Show loading while resolving user data. + if ( isResolving || isLoading ) { + return ( +
+
+ +
+
+ ); + } + + if ( ! stats ) { + return ( +
+

{ __( 'No statistics available yet.', 'activitypub' ) }

+
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/src/dashboard-stats/components/top-posts/index.tsx b/src/dashboard-stats/components/top-posts/index.tsx new file mode 100644 index 0000000000..091b46ed6d --- /dev/null +++ b/src/dashboard-stats/components/top-posts/index.tsx @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { TopPost } from '../../types'; + +interface Props { + posts: TopPost[] | null | undefined; +} + +/** + * Top Posts Component. + * + * @param {Props} props Component props. + */ +export default function TopPosts( { posts }: Props ): ReactNode { + if ( ! posts?.length ) { + return null; + } + + return ( +
+

{ __( 'Top Posts', 'activitypub' ) }

+
    + { posts.map( ( post ) => { + const title = post.title || __( '(no title)', 'activitypub' ); + return ( +
  • + + { title } + + + { sprintf( + /* translators: %s: engagement count */ + __( '%s engagements', 'activitypub' ), + post.engagement_count.toLocaleString() + ) } + +
  • + ); + } ) } +
+
+ ); +} diff --git a/src/dashboard-stats/components/top-supporter/index.tsx b/src/dashboard-stats/components/top-supporter/index.tsx new file mode 100644 index 0000000000..c901a3c492 --- /dev/null +++ b/src/dashboard-stats/components/top-supporter/index.tsx @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { Multiplicator } from '../../types'; + +interface Props { + multiplicator: Multiplicator | null | undefined; +} + +/** + * Top Supporter Component. + * + * @param {Props} props Component props. + */ +export default function TopSupporter( { multiplicator }: Props ): ReactNode { + if ( ! multiplicator?.name ) { + return null; + } + + return ( +
+

{ __( 'Top Supporter', 'activitypub' ) }

+

+ + { multiplicator.name } + { ' ' } + { sprintf( + /* translators: %s: number of boosts */ + _n( '(%s boost)', '(%s boosts)', multiplicator.count, 'activitypub' ), + multiplicator.count.toLocaleString() + ) } +

+
+ ); +} diff --git a/src/dashboard-stats/index.tsx b/src/dashboard-stats/index.tsx new file mode 100644 index 0000000000..76c7fbdd59 --- /dev/null +++ b/src/dashboard-stats/index.tsx @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import StatsWidget from './components/stats-widget'; +import './style.scss'; + +declare global { + interface Window { + activitypub: { + dashboardStats?: { + initialize: ( id: string ) => void; + }; + }; + } +} + +/** + * Initialize the dashboard stats widget. + * + * @param {string} id The container element ID. + */ +export function initialize( id: string ) { + const container = document.getElementById( id ); + + if ( ! container ) { + return; + } + + const root = createRoot( container ); + root.render( ); +} + +// Export to window for inline script access. +window.activitypub = window.activitypub || {}; +window.activitypub.dashboardStats = { initialize }; diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss new file mode 100644 index 0000000000..07a2f97ac4 --- /dev/null +++ b/src/dashboard-stats/style.scss @@ -0,0 +1,255 @@ +// ActivityPub Dashboard Stats Widget +// Styled to match WordPress core dashboard widgets exactly. + +.activitypub-stats-widget { + // Match WordPress dashboard postbox inner padding. + margin: -6px -12px -12px; + + // Match #dashboard-widgets h3 styling. + h3 { + margin: 0 12px 8px; + padding: 0; + font-size: 14px; + font-weight: 400; + color: #1d2327; + } +} + +.activitypub-stats-loading { + display: flex; + justify-content: center; + padding: 24px 12px; +} + +.activitypub-stats-empty { + color: #646970; + text-align: center; + padding: 24px 12px; + margin: 0; +} + +// Highlights section - matches #dashboard_right_now .main exactly. +.activitypub-stats-highlights { + padding: 0 12px 11px; + + h3 { + margin-left: 0; + margin-right: 0; + } + + // Match #dashboard_right_now ul exactly. + ul { + margin: 0; + display: inline-block; + width: 100%; + } + + // Match #dashboard_right_now li exactly. + li { + width: 50%; + float: left; + margin-bottom: 10px; + } + + // Match #dashboard_right_now li a:before exactly. + li a::before, + li > span:not(.stat-change)::before { + content: "\f159"; + color: #646970; + font: 400 20px/1 dashicons, sans-serif; + padding: 0 5px 0 0; + display: inline-block; + position: relative; + vertical-align: top; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-decoration: none; + } + + // Icon mappings - same as WordPress dashboard icons. + .activitypub-followers-count a::before { + content: "\f307"; // groups icon. + } + + .activitypub-posts-count a::before { + content: "\f109"; // admin-post icon. + } + + .activitypub-like-count a::before, + .activitypub-likes-count a::before { + content: "\f155"; // star-filled icon. + } + + .activitypub-repost-count a::before, + .activitypub-reposts-count a::before { + content: "\f488"; // share icon. + } + + .activitypub-comment-count a::before, + .activitypub-comments-count a::before { + content: "\f101"; // admin-comments icon. + } + + .stat-change { + color: #646970; + + &.positive { + color: #00a32a; + } + + &.negative { + color: #d63638; + } + } +} + +// Sub-section style - matches #dashboard_right_now .sub exactly. +.activitypub-stats-sub { + color: #50575e; + background: #f6f7f7; + border-top: 1px solid #f0f0f1; + padding: 10px 12px 6px; + + h3 { + color: #50575e; + margin-left: 0; + margin-right: 0; + } +} + +// Chart section. +.activitypub-stats-chart { + border-top: 1px solid #f0f0f1; + padding: 8px 12px 4px; + + h3 { + margin-left: 0; + margin-right: 0; + } +} + +.activitypub-chart-container { + background: #f6f7f7; + border-bottom: 1px solid #f0f0f1; + margin: 0 -12px 6px; + padding: 8px 12px; +} + +.activitypub-line-chart { + width: 100%; + height: auto; + display: block; + + .chart-label { + font-size: 10px; + fill: #646970; + } +} + +.activitypub-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #f0f0f1; +} + +.activitypub-legend-item { + display: flex; + align-items: center; + font-size: 11px; + color: #50575e; + + .legend-color { + width: 12px; + height: 3px; + border-radius: 1px; + margin-right: 6px; + } +} + +// Top supporter section. +.activitypub-stats-multiplicator { + border-top: 1px solid #f0f0f1; + padding: 12px; + + h3 { + margin-left: 0; + margin-right: 0; + margin-bottom: 4px; + } + + p { + margin: 0; + color: #50575e; + } + + /* stylelint-disable-next-line no-descending-specificity */ + a { + text-decoration: none; + font-weight: 600; + color: #2271b1; + + &:hover { + text-decoration: underline; + color: #135e96; + } + } +} + +// Top posts section. +.activitypub-stats-top-posts { + border-top: 1px solid #f0f0f1; + padding: 12px; + + h3 { + margin-left: 0; + margin-right: 0; + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + color: #50575e; + } + + /* stylelint-disable-next-line no-descending-specificity */ + a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + margin-right: 10px; + color: #2271b1; + + &:hover { + text-decoration: underline; + color: #135e96; + } + } + + .engagement-count { + font-size: 12px; + color: #646970; + white-space: nowrap; + } +} + +// Responsive adjustments. + +@media screen and (max-width: 782px) { + + .activitypub-stats-highlights li { + width: 100%; + } +} diff --git a/src/dashboard-stats/types/index.ts b/src/dashboard-stats/types/index.ts new file mode 100644 index 0000000000..fb8fbb60ce --- /dev/null +++ b/src/dashboard-stats/types/index.ts @@ -0,0 +1,53 @@ +export interface StatComparison { + current: number; + change: number; +} + +export interface Comparison { + followers?: StatComparison; + posts?: StatComparison; + // Dynamic keys for engagement types (like, repost, quote, comment, etc.) + [ key: string ]: StatComparison | undefined; +} + +export interface MonthData { + month: number; + year?: number; + posts_count: number; + engagement: number; + // Dynamic keys for engagement type counts (like_count, repost_count, quote_count, etc.) + [ key: string ]: number | undefined; +} + +export interface CommentType { + slug: string; + label: string; + singular: string; +} + +export interface Multiplicator { + name: string; + url: string; + count: number; +} + +export interface TopPost { + post_id: number; + title: string; + url: string; + engagement_count: number; +} + +export interface Stats { + posts_count: number; + followers_total: number; + top_posts: TopPost[]; + top_multiplicator: Multiplicator | null; +} + +export interface StatsResponse { + stats: Stats; + comparison: Comparison; + monthly: MonthData[]; + comment_types: Record< string, CommentType >; +} diff --git a/templates/emails/annual-wrapped.php b/templates/emails/annual-wrapped.php new file mode 100644 index 0000000000..e5e93532d7 --- /dev/null +++ b/templates/emails/annual-wrapped.php @@ -0,0 +1,231 @@ + + + +

+ +

+ +

+ +

+ +
+
+ + +
+ $type_info ) : ?> +
+ + +
+ +
+ + +
+

+

+
+ + +
+

+ = 0 ? '' : 'negative'; + $change_sign = $net_change >= 0 ? '+' : ''; + ?> +

+ +

+

+ ' . esc_html( number_format_i18n( $args['followers_start'] ?? 0 ) ) . '', + '' . esc_html( number_format_i18n( $args['followers_end'] ?? 0 ) ) . '' + ), + array( 'strong' => array() ) + ); + ?> +

+
+ + +
+

+

+ ' . esc_html( $args['top_multiplicator']['name'] ) . '', + '' . esc_html( number_format_i18n( $args['top_multiplicator']['count'] ?? 0 ) ) . '' + ), + array( + 'strong' => array(), + 'a' => array( 'href' => array() ), + ) + ); + ?> +

+
+ + +

+ +

+ +