Skip to content

Link to the Fediverse Stats dashboard widget from the stats emails - #4015

Draft
allilevine wants to merge 6 commits into
trunkfrom
add/stats-email-dashboard-link
Draft

allilevine wants to merge 6 commits into
trunkfrom
add/stats-email-dashboard-link

Conversation

@allilevine

@allilevine allilevine commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #4014

Proposed changes:

  • The monthly and annual Fediverse stats emails now include a View your stats button linking to index.php#activitypub_stats, the anchor of the Fediverse Stats dashboard widget. Before this, the only links in those emails were the top post and the Manage notification settings footer link.
  • The plain text alternative of both emails carries the same URL.
  • The link is only added when the recipient would actually see the widget. Statistics::get_stats_url() defers to Activitypub\user_can_see_stats(), the gate the dashboard widget itself registers on, which this PR moves from WP_Admin\Dashboard into functions-user.php so the scheduler does not reach into the admin layer from cron. The blog actor is special-cased there: it is not a WordPress user (its ID is 0), and its report goes to admin_email, which stands in for the gate's manage_options branch.
  • The URL is passed to the template as a stats_url arg, so templates/emails/stats-report.php stays logic-free and renders nothing when the arg is empty.

One deliberate trade-off: for the blog actor the report goes to admin_email, which the widget's manage_options branch assumes is the site admin. The code does not resolve admin_email back to a WP_User, since that address is often a distribution list with no account, and dropping the link in that common case seemed worse than linking for a rare address without manage_options.

Screenshot 2026-11-04 at 4 34 30 PM

Other information:

  • Have you written new tests for your changes, if applicable?

Five tests added to tests/phpunit/tests/includes/scheduler/class-test-statistics.php: the link is present in the monthly and annual reports, present for the blog actor when the blog profile is enabled, absent when the recipient cannot see the widget, and present in the plain text alternative. Four of the five fail without the implementation.

Testing instructions:

  1. In wp-env (or any dev site), enable the monthly report for user 1 and seed a month of stats, then send the report:
wp eval '
update_user_option( 1, "activitypub_mailer_monthly_report", "1" );
update_option( Activitypub\Statistics::get_monthly_option_name( 1, 2026, 9 ), array( "posts_count" => 4, "followers_count" => 7, "followers_total" => 42, "like_count" => 12 ) );
add_filter( "pre_wp_mail", function ( $sc, $atts ) { file_put_contents( ABSPATH . "wp-content/stats-email.html", $atts["message"] ); return true; }, 10, 2 );
Activitypub\Scheduler\Statistics::send_monthly_email( 1, 2026, 9, true );
'
  1. Open /wp-content/stats-email.html in a browser. Below the closing line there should be a View your stats button.
  2. Click it. The dashboard loads scrolled to the Fediverse Stats widget.
  3. Re-run step 1 with add_filter( 'activitypub_user_can_activitypub', '__return_false' ); prepended. The button should be gone, and the rest of the email unchanged.
  4. The same applies to send_annual_email().

Changelog entry

Changelog entry added in .github/changelog/add-stats-email-dashboard-link (Minor / Added).

The monthly and annual stats emails had no link to the stats themselves.
They now include a "View your stats" button pointing at the Fediverse
Stats dashboard widget, and the plain text alternative carries the URL.

The link is only added when the recipient would actually see the widget:
user_can_activitypub() mirrors the widget's own gate for both the blog
actor (whose report goes to admin_email) and individual users.

Fixes #4014
Copilot AI balanced review requested due to automatic review settings November 4, 2026 21:11
@allilevine allilevine self-assigned this Nov 4, 2026
@allilevine
allilevine requested a review from a team November 4, 2026 21:11
@allilevine
allilevine marked this pull request as draft November 4, 2026 21:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The stats-link helper can include a dashboard link for a non-admin recipient in blog-only mode when the capability filter returns true, without checking whether the recipient’s actor type is enabled.

1 open issue
What changed in this PR

Adds a direct route from monthly and annual Fediverse stats emails to the existing dashboard widget, addressing #4014.

Changes:

  • Adds a conditional View your stats button and matching plain-text URL.
  • Centralizes recipient eligibility checks in the statistics scheduler.
  • Adds five tests covering link inclusion, omission, blog recipients, and plain-text output.
File Description
tests/​phpunit/​tests/​includes/​scheduler/​class-test-statistics.php Captures email content and tests stats links.
templates/​emails/​stats-report.php Renders the optional stats button.
includes/​scheduler/​class-statistics.php Computes the recipient’s stats URL and adds it to both email formats.
.github/​changelog/​add-stats-email-dashboard-link Documents the new dashboard link.

Review effort: Balanced

Comment thread includes/scheduler/class-statistics.php Outdated
Comment on lines +326 to +328
if ( ! user_can_activitypub( $user_id ) ) {
return '';
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a977baa, though the reachable path is narrower than described and the suggested fix would have introduced the opposite bug.

On reachability: the scheduled path can't hit this. collect_all_monthly_stats() draws recipients from Actors::get_all_ids(), which skips get_users() entirely when is_user_type_disabled( 'user' ), so in blog-only mode no user-actor email is sent no matter what the capability filter returns. The divergence is only reachable through a direct call to the public send_monthly_email() / send_annual_email() — a test send, WP-CLI, or a host calling them itself. Still worth fixing, since those methods are public API.

On the suggested fix: adding is_user_type_disabled( 'user' ) to the existing condition would close this case but open a false negative. An administrator on a blog-only site does see the widget, through user_can_see_stats()'s second branch (! is_user_type_disabled( 'blog' ) && manage_options). Gating on the user branch alone would strip the link from their email even though the widget is right there.

So instead of re-deriving the gate, get_stats_url() now defers to it. Dashboard::user_can_see_stats() is public and takes an optional user ID (defaulting to the current user, so the two existing call sites are unchanged), and current_user_can() became user_can( $user_id, ... ). The blog actor stays special-cased, since it isn't a WordPress user and can't be passed to that gate — its report goes to admin_email, which stands in for the manage_options branch. One source of truth, so the two can't drift again.

Tests added — both fail against the previous implementation:

  • test_send_monthly_email_omits_stats_link_when_user_mode_disabled — blog-only mode with activitypub_user_can_activitypub forced true, asserting no link. This is the case you flagged.
  • test_send_monthly_email_links_to_stats_widget_for_admin_in_blog_mode — an administrator in blog-only mode, asserting the link is present. This is the case the suggested fix would have broken.

Plus tests/phpunit/tests/includes/wp-admin/class-test-dashboard.php for the now-public method, covering the current-user default, an explicit user ID, both actor modes, and the forced-capability case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my reply above: I wrote that the blog actor's ID is -1. It is 0 (Actors::BLOG_USER_ID; -1 is APPLICATION_USER_ID). The code was right — it compares against the constant — but the reasoning I gave for it was not, and that mattered more than it looked.

Because BLOG_USER_ID is 0, user_can_activitypub( 0 ) matches the blog branch of its switch and returns ! is_user_type_disabled( 'blog' ). So the gate I had just made public returned true for user 0 whenever the blog actor was enabled — including the current-user default on a logged out request. Unreachable inside wp_dashboard_setup, which is why it had never mattered, but I had just widened it to public and called it from cron.

Fixed in 4fbac09, along with the other point: the gate no longer lives in the admin layer at all. includes/scheduler/class-statistics.php held the only reference to the WP_Admin namespace from anywhere in includes/ outside includes/wp-admin/, and Dashboard is registered on admin_init, so a future is_admin() guard or a gate that read admin-request state would have fataled the cron mail path silently. user_can_see_stats() now sits in includes/functions-user.php next to user_can_activitypub() and is_user_type_disabled(); the widget and the scheduler both call it, and it rejects any ID at or below BLOG_USER_ID up front.

My earlier test asserted assertFalse( user_can_see_stats( 0 ) ) but passed only because the default actor mode disables the blog actor — it would have flipped under actor_blog. It is replaced by test_user_can_see_stats_rejects_non_users, which loops all three actor modes and also covers APPLICATION_USER_ID; I confirmed it fails in actor_blog with the guard removed. The dashboard test file is gone, since the method it covered is gone — those tests moved to class-test-functions-user.php.

Also added test_send_annual_email_adds_stats_url_to_alt_body; the annual plain-text branch was genuinely uncovered.

Matches Mailer::send() and satisfies the snake_case property sniff
without an ignore comment.
get_stats_url() mirrored the widget gate with user_can_activitypub(),
which the activitypub_user_can_activitypub filter can force true. The
widget re-checks the actor type after that filter, so a forced
capability in blog-only mode produced a link to a dashboard with no
widget. Defer to Dashboard::user_can_see_stats() instead, now public and
accepting a user ID, so there is one source of truth.

This also fixes the inverse: an administrator on a blog-only site sees
the widget through its manage_options branch and now keeps the link.
The scheduler reached into Activitypub\WP_Admin\Dashboard from a cron
path, the only reference to that namespace from includes/ outside
includes/wp-admin/. The gate now lives in functions-user.php beside
user_can_activitypub() and is_user_type_disabled(), and both the widget
and the scheduler call it.

It also rejects non-user IDs. BLOG_USER_ID is 0, so user_can_activitypub( 0 )
answers for the blog actor, which made user_can_see_stats( 0 ) - including
the current-user default on a logged out request - return true whenever the
blog actor was enabled.

Adds the missing annual alt-body test.
Test_Functions_User has no @coversDefaultClass, so "::user_can_see_stats"
resolved to a global symbol that does not exist and PHPUnit warned on each
test. Use the fully qualified function name, as the other tests in the file
already do.
The previous push moved the branch ref but GitHub did not update the PR
or run CI against it. Empty commit; safe to drop.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fediverse stats email: link to the Fediverse Stats dashboard widget

2 participants