From b175e9053ff67c82faedbc73396e951d0739a2a5 Mon Sep 17 00:00:00 2001
From: Konstantin Obenland
Date: Tue, 30 Sep 2025 13:01:52 -0500
Subject: [PATCH] Assets: Build JS & CSS files
This change reorganizes the JavaScript and CSS build structure:
- Moves blocks into dedicated /src/blocks and /build/blocks folders
- Moves non-block assets into /src/wp-admin, /src/embed
- Updates webpack configuration for new paths
- Adds comprehensive developer documentation
- Updates all PHP enqueue paths accordingly
---
.distignore | 2 +
.editorconfig | 4 +-
.github/changelog/1674-from-description | 5 +
.github/changelog/1900-from-description | 4 +
.github/changelog/1907-from-description | 4 +
.github/changelog/1909-from-description | 4 +
.github/changelog/1913-from-description | 4 +
.github/changelog/1916-from-description | 4 +
.github/changelog/1918-from-description | 4 +
.github/changelog/1919-from-description | 4 +
.github/changelog/1920-from-description | 4 +
.github/changelog/1922-from-description | 4 +
.github/changelog/1925-from-description | 4 +
.github/changelog/1928-from-description | 4 +
.github/changelog/1930-from-description | 4 +
.github/changelog/1931-from-description | 4 +
.github/changelog/1932-from-description | 4 +
.github/changelog/1942-from-description | 4 +
.github/changelog/1943-from-description | 4 +
.github/changelog/1946-from-description | 4 +
.github/changelog/1959-from-description | 4 +
.github/changelog/1973-from-description | 4 +
.github/changelog/1974-from-description | 4 +
.github/workflows/gardening.yml | 2 +
.github/workflows/phpunit.yml | 2 +-
.gitignore | 15 +-
.prettierignore | 10 +-
.prettierrc.js | 12 -
.wordpress-org/blueprints/blueprint.json | 82 +-
.wordpress-org/icon.svg | 9 +
.wp-env.json | 34 +-
CHANGELOG.md | 118 ++-
FEDERATION.md | 5 +-
activitypub.php | 53 +-
build/{ => blocks}/editor-plugin/block.json | 0
build/blocks/editor-plugin/plugin.asset.php | 1 +
build/blocks/editor-plugin/plugin.js | 1 +
build/{ => blocks}/follow-me/block.json | 57 +-
build/blocks/follow-me/index.asset.php | 1 +
build/blocks/follow-me/index.js | 2 +
build/blocks/follow-me/render.php | 244 +++++
build/blocks/follow-me/style-index-rtl.css | 1 +
build/blocks/follow-me/style-index.css | 1 +
.../{ => blocks}/follow-me/style-view-rtl.css | 0
build/{ => blocks}/follow-me/style-view.css | 0
build/blocks/follow-me/view.asset.php | 1 +
build/blocks/follow-me/view.js | 1 +
build/{ => blocks}/followers/block.json | 27 +-
build/{ => blocks}/followers/index.asset.php | 2 +-
build/blocks/followers/index.js | 2 +
build/blocks/followers/render.php | 168 ++++
build/blocks/followers/style-index-rtl.css | 1 +
build/blocks/followers/style-index.css | 1 +
.../{ => blocks}/followers/style-view-rtl.css | 0
build/{ => blocks}/followers/style-view.css | 0
build/blocks/followers/view.asset.php | 1 +
build/blocks/followers/view.js | 1 +
build/blocks/reactions/block.json | 51 +
build/blocks/reactions/index.asset.php | 1 +
build/blocks/reactions/index.js | 3 +
build/blocks/reactions/render.php | 218 +++++
build/blocks/reactions/style-index-rtl.css | 1 +
build/blocks/reactions/style-index.css | 1 +
build/blocks/reactions/view.asset.php | 1 +
build/blocks/reactions/view.js | 1 +
build/{ => blocks}/remote-reply/block.json | 7 +-
.../{ => blocks}/remote-reply/index.asset.php | 2 +-
build/{ => blocks}/remote-reply/index.js | 4 +-
build/blocks/remote-reply/render.php | 192 ++++
.../remote-reply/style-index-rtl.css | 0
.../{ => blocks}/remote-reply/style-index.css | 0
build/blocks/remote-reply/style-view.css | 1 +
build/blocks/remote-reply/view.asset.php | 1 +
build/blocks/remote-reply/view.js | 1 +
build/{ => blocks}/reply-intent/block.json | 0
.../reply-intent/plugin.asset.php | 0
build/{ => blocks}/reply-intent/plugin.js | 0
build/{ => blocks}/reply/block.json | 0
build/{ => blocks}/reply/index-rtl.css | 2 +-
build/{ => blocks}/reply/index.asset.php | 2 +-
build/{ => blocks}/reply/index.css | 2 +-
build/blocks/reply/index.js | 1 +
build/editor-plugin/plugin.asset.php | 1 -
build/editor-plugin/plugin.js | 1 -
build/embed/embed-rtl.css | 1 +
build/embed/embed.css | 1 +
build/follow-me/index.asset.php | 1 -
build/follow-me/index.js | 3 -
build/follow-me/view.asset.php | 1 -
build/follow-me/view.js | 2 -
build/followers/index.js | 4 -
build/followers/view.asset.php | 1 -
build/followers/view.js | 3 -
build/reactions/block.json | 32 -
build/reactions/index.asset.php | 1 -
build/reactions/index.js | 3 -
build/reactions/style-index-rtl.css | 1 -
build/reactions/style-index.css | 1 -
build/reactions/view.asset.php | 1 -
build/reactions/view.js | 1 -
build/reply/index.js | 1 -
build/reply/style-index-rtl.css | 1 -
build/reply/style-index.css | 1 -
build/wp-admin/admin-rtl.css | 3 +
build/wp-admin/admin.css | 3 +
build/wp-admin/header-image.asset.php | 1 +
build/wp-admin/header-image.js | 4 +
build/wp-admin/post-preview-rtl.css | 1 +
build/wp-admin/post-preview.css | 1 +
build/wp-admin/script.asset.php | 1 +
build/wp-admin/script.js | 1 +
build/wp-admin/welcome-rtl.css | 1 +
build/wp-admin/welcome.css | 1 +
composer.json | 128 +--
docs/developer-docs.md | 173 ++++
docs/how-to/readme.md | 3 +
docs/how-to/reverse-proxy.md | 17 +
docs/how-to/wordpress-in-a-subdir.md | 24 +
docs/readme.md | 7 +
includes/activity/class-actor.php | 87 ++
includes/activity/class-base-object.php | 11 +
includes/activity/class-generic-object.php | 67 +-
includes/class-activitypub.php | 110 ++-
includes/class-blocks.php | 319 +++---
includes/class-cli.php | 28 +
includes/class-comment.php | 154 ++-
includes/class-debug.php | 20 +-
includes/class-dispatcher.php | 40 -
includes/class-embed.php | 4 +-
includes/class-handler.php | 8 +-
includes/class-hashtag.php | 5 +-
includes/class-http.php | 43 +-
includes/class-link.php | 5 +-
includes/class-mailer.php | 22 +-
includes/class-migration.php | 151 ++-
includes/class-options.php | 3 +-
includes/class-query.php | 63 +-
includes/class-sanitize.php | 63 ++
includes/class-scheduler.php | 83 +-
includes/class-shortcodes.php | 26 +-
includes/class-signature.php | 452 ++++-----
includes/class-webfinger.php | 22 +-
includes/collection/class-actors.php | 620 +++++++++++-
includes/collection/class-followers.php | 358 +++----
includes/collection/class-following.php | 435 +++++++++
includes/collection/class-interactions.php | 32 +-
includes/collection/class-outbox.php | 54 +-
includes/collection/class-users.php | 78 --
includes/compat.php | 30 +-
includes/debug.php | 7 +
includes/functions.php | 217 +++--
includes/handler/class-accept.php | 120 +++
includes/handler/class-delete.php | 8 +-
includes/handler/class-follow.php | 32 +-
includes/handler/class-move.php | 44 +-
includes/handler/class-reject.php | 119 +++
includes/handler/class-update.php | 11 +-
includes/model/class-application.php | 19 +-
includes/model/class-blog.php | 80 +-
includes/model/class-follower.php | 151 +--
includes/model/class-user.php | 49 +-
includes/rest/class-actors-controller.php | 22 +
.../rest/class-application-controller.php | 15 +
includes/rest/class-followers-controller.php | 4 +-
includes/rest/class-following-controller.php | 142 ++-
.../rest/class-interaction-controller.php | 33 +-
includes/rest/class-post-controller.php | 5 +-
includes/scheduler/class-post.php | 11 +-
.../class-http-message-signature.php | 456 +++++++++
.../signature/class-http-signature-draft.php | 329 +++++++
.../signature/interface-http-signature.php | 45 +
includes/table/class-followers.php | 357 +++++--
includes/table/class-following.php | 505 ++++++++++
includes/table/trait-actor-list-table.php | 28 +
includes/transformer/class-base.php | 58 +-
includes/transformer/class-post.php | 16 +-
includes/wp-admin/class-admin.php | 129 +--
.../class-advanced-settings-fields.php | 27 +
.../wp-admin/class-blog-settings-fields.php | 7 +-
includes/wp-admin/class-health-check.php | 28 +-
includes/wp-admin/class-menu.php | 25 +-
includes/wp-admin/class-screen-options.php | 170 ++++
includes/wp-admin/class-settings-fields.php | 27 +-
includes/wp-admin/class-settings.php | 151 +--
.../wp-admin/class-user-settings-fields.php | 8 +-
includes/wp-admin/class-welcome-fields.php | 47 +-
.../wp-admin/import/class-starter-kit.php | 298 ++++++
includes/wp-admin/import/load.php | 9 +
integration/class-jetpack.php | 4 +-
integration/class-surge.php | 8 +
integration/class-wp-rest-cache.php | 148 +++
integration/load.php | 6 +
package.json | 101 +-
phpcs.xml | 2 +-
readme.txt | 365 ++-----
src/blocks/editor-plugin/block.json | 8 +
src/{ => blocks}/editor-plugin/plugin.js | 101 +-
src/{ => blocks}/follow-me/block.json | 50 +-
src/blocks/follow-me/button-style.js | 164 ++++
src/blocks/follow-me/deprecation.js | 208 ++++
src/blocks/follow-me/edit.js | 252 +++++
src/blocks/follow-me/index.js | 10 +
src/blocks/follow-me/render.php | 244 +++++
src/blocks/follow-me/save.js | 17 +
src/blocks/follow-me/style.scss | 240 +++++
src/blocks/follow-me/view.js | 214 ++++
src/{ => blocks}/followers/block.json | 24 +-
src/blocks/followers/deprecations.js | 58 ++
src/blocks/followers/edit.js | 299 ++++++
src/blocks/followers/index.js | 9 +
src/blocks/followers/render.php | 168 ++++
src/blocks/followers/save.js | 13 +
src/blocks/followers/style.scss | 290 ++++++
src/blocks/followers/view.js | 148 +++
src/blocks/reactions/block.json | 48 +
src/blocks/reactions/deprecation.js | 64 ++
src/blocks/reactions/edit.js | 77 ++
src/{ => blocks}/reactions/index.js | 6 +-
src/blocks/reactions/reactions.js | 192 ++++
src/blocks/reactions/render.php | 218 +++++
src/blocks/reactions/save.js | 22 +
src/blocks/reactions/style.scss | 176 ++++
src/blocks/reactions/view.js | 146 +++
src/{ => blocks}/remote-reply/block.json | 7 +-
src/blocks/remote-reply/render.php | 192 ++++
src/blocks/remote-reply/style.scss | 110 +++
src/blocks/remote-reply/view.js | 272 ++++++
src/{ => blocks}/reply-intent/block.json | 8 +-
src/{ => blocks}/reply-intent/plugin.js | 0
src/{ => blocks}/reply/block.json | 0
src/{ => blocks}/reply/edit.js | 96 +-
src/{ => blocks}/reply/editor.scss | 4 +-
src/{ => blocks}/reply/index.js | 0
.../style.css => blocks/reply/style.scss} | 0
.../shared/inherit-block-fallback.js | 2 +-
src/blocks/shared/modal/README.md | 171 ++++
src/blocks/shared/modal/index.js | 290 ++++++
src/blocks/shared/modal/style.scss | 110 +++
src/{ => blocks}/shared/use-options.js | 0
src/{ => blocks}/shared/use-user-options.js | 17 +-
src/editor-plugin/block.json | 8 -
.../embed/embed.css | 0
src/follow-me/button-style.js | 75 --
src/follow-me/edit.js | 109 ---
src/follow-me/follow-me.js | 242 -----
src/follow-me/index.js | 5 -
src/follow-me/style.scss | 52 -
src/follow-me/view.js | 16 -
src/followers/edit.js | 99 --
src/followers/followers.js | 126 ---
src/followers/index.js | 5 -
src/followers/pagination-page.js | 18 -
src/followers/pagination.js | 82 --
src/followers/pagination.scss | 179 ----
src/followers/style.scss | 84 --
src/followers/view.js | 12 -
src/reactions/block.json | 29 -
src/reactions/deprecation.js | 43 -
src/reactions/edit.js | 170 ----
src/reactions/editor.scss | 4 -
src/reactions/reactions.js | 375 -------
src/reactions/save.js | 10 -
src/reactions/style.scss | 135 ---
src/reactions/view.js | 14 -
src/remote-reply/index.js | 16 -
src/remote-reply/remote-reply.js | 80 --
src/remote-reply/style.scss | 17 -
src/shared/dialog.js | 163 ----
src/shared/lightbox.scss | 76 --
src/shared/use-remote-user.js | 66 --
.../wp-admin/admin.scss | 27 +-
.../wp-admin/header-image.js | 30 +-
.../wp-admin/post-preview.css | 0
.../wp-admin/script.js | 12 +-
.../wp-admin/welcome.css | 0
templates/admin-header.php | 1 -
templates/advanced-settings.php | 2 +
templates/blog-followers-list.php | 25 -
templates/blog-settings.php | 2 +
templates/embed.php | 3 +-
templates/followers-list.php | 50 +
templates/following-list.php | 87 ++
templates/help-tab/template-tags.php | 2 -
templates/post-preview.php | 5 +-
templates/settings.php | 2 +
templates/user-followers-list.php | 26 -
templates/welcome.php | 8 +
tests/fixtures/http-signature-keys.json | 41 +
tests/includes/class-test-activitypub.php | 69 +-
tests/includes/class-test-blocks.php | 65 +-
tests/includes/class-test-comment.php | 131 ++-
tests/includes/class-test-compat.php | 33 +
tests/includes/class-test-dispatcher.php | 71 --
tests/includes/class-test-functions.php | 251 +++++
tests/includes/class-test-hashtag.php | 7 +-
tests/includes/class-test-link.php | 4 +-
tests/includes/class-test-mention.php | 2 +-
tests/includes/class-test-migration.php | 190 +++-
tests/includes/class-test-move.php | 2 +-
tests/includes/class-test-sanitize.php | 2 +
tests/includes/class-test-scheduler.php | 66 ++
tests/includes/class-test-signature.php | 915 +++++++++++++-----
tests/includes/class-test-webfinger.php | 222 +++++
.../includes/collection/class-test-actors.php | 610 +++++++++++-
.../collection/class-test-followers.php | 219 ++---
.../collection/class-test-following.php | 465 +++++++++
.../collection/class-test-interactions.php | 88 +-
.../includes/collection/class-test-outbox.php | 4 +-
tests/includes/handler/class-test-accept.php | 167 ++++
.../includes/handler/class-test-announce.php | 2 +-
tests/includes/handler/class-test-follow.php | 42 +-
tests/includes/handler/class-test-move.php | 84 +-
tests/includes/handler/class-test-reject.php | 168 ++++
tests/includes/handler/class-test-update.php | 31 +-
tests/includes/model/class-test-blog.php | 2 +-
tests/includes/model/class-test-follower.php | 77 +-
tests/includes/model/class-test-user.php | 2 +-
.../class-test-actors-inbox-controller.php | 46 +-
.../rest/class-test-followers-controller.php | 13 +-
.../rest/class-test-following-controller.php | 155 ++-
.../rest/class-test-inbox-controller.php | 6 +
.../class-test-interaction-controller.php | 27 +-
.../rest/class-test-post-controller.php | 39 +-
tests/includes/rest/class-test-server.php | 2 +-
.../class-test-signature-verification.php | 150 ---
.../rest/class-test-trait-collection.php | 2 +-
.../includes/transformer/class-test-base.php | 146 ++-
.../includes/transformer/class-test-post.php | 85 +-
.../wp-admin/class-test-welcome-fields.php | 116 +++
tests/integration/class-test-surge.php | 2 +
webpack.config.js | 50 +
331 files changed, 16033 insertions(+), 5880 deletions(-)
create mode 100644 .github/changelog/1674-from-description
create mode 100644 .github/changelog/1900-from-description
create mode 100644 .github/changelog/1907-from-description
create mode 100644 .github/changelog/1909-from-description
create mode 100644 .github/changelog/1913-from-description
create mode 100644 .github/changelog/1916-from-description
create mode 100644 .github/changelog/1918-from-description
create mode 100644 .github/changelog/1919-from-description
create mode 100644 .github/changelog/1920-from-description
create mode 100644 .github/changelog/1922-from-description
create mode 100644 .github/changelog/1925-from-description
create mode 100644 .github/changelog/1928-from-description
create mode 100644 .github/changelog/1930-from-description
create mode 100644 .github/changelog/1931-from-description
create mode 100644 .github/changelog/1932-from-description
create mode 100644 .github/changelog/1942-from-description
create mode 100644 .github/changelog/1943-from-description
create mode 100644 .github/changelog/1946-from-description
create mode 100644 .github/changelog/1959-from-description
create mode 100644 .github/changelog/1973-from-description
create mode 100644 .github/changelog/1974-from-description
create mode 100644 .wordpress-org/icon.svg
rename build/{ => blocks}/editor-plugin/block.json (100%)
create mode 100644 build/blocks/editor-plugin/plugin.asset.php
create mode 100644 build/blocks/editor-plugin/plugin.js
rename build/{ => blocks}/follow-me/block.json (61%)
create mode 100644 build/blocks/follow-me/index.asset.php
create mode 100644 build/blocks/follow-me/index.js
create mode 100644 build/blocks/follow-me/render.php
create mode 100644 build/blocks/follow-me/style-index-rtl.css
create mode 100644 build/blocks/follow-me/style-index.css
rename build/{ => blocks}/follow-me/style-view-rtl.css (100%)
rename build/{ => blocks}/follow-me/style-view.css (100%)
create mode 100644 build/blocks/follow-me/view.asset.php
create mode 100644 build/blocks/follow-me/view.js
rename build/{ => blocks}/followers/block.json (71%)
rename build/{ => blocks}/followers/index.asset.php (81%)
create mode 100644 build/blocks/followers/index.js
create mode 100644 build/blocks/followers/render.php
create mode 100644 build/blocks/followers/style-index-rtl.css
create mode 100644 build/blocks/followers/style-index.css
rename build/{ => blocks}/followers/style-view-rtl.css (100%)
rename build/{ => blocks}/followers/style-view.css (100%)
create mode 100644 build/blocks/followers/view.asset.php
create mode 100644 build/blocks/followers/view.js
create mode 100644 build/blocks/reactions/block.json
create mode 100644 build/blocks/reactions/index.asset.php
create mode 100644 build/blocks/reactions/index.js
create mode 100644 build/blocks/reactions/render.php
create mode 100644 build/blocks/reactions/style-index-rtl.css
create mode 100644 build/blocks/reactions/style-index.css
create mode 100644 build/blocks/reactions/view.asset.php
create mode 100644 build/blocks/reactions/view.js
rename build/{ => blocks}/remote-reply/block.json (58%)
rename build/{ => blocks}/remote-reply/index.asset.php (67%)
rename build/{ => blocks}/remote-reply/index.js (93%)
create mode 100644 build/blocks/remote-reply/render.php
rename build/{ => blocks}/remote-reply/style-index-rtl.css (100%)
rename build/{ => blocks}/remote-reply/style-index.css (100%)
create mode 100644 build/blocks/remote-reply/style-view.css
create mode 100644 build/blocks/remote-reply/view.asset.php
create mode 100644 build/blocks/remote-reply/view.js
rename build/{ => blocks}/reply-intent/block.json (100%)
rename build/{ => blocks}/reply-intent/plugin.asset.php (100%)
rename build/{ => blocks}/reply-intent/plugin.js (100%)
rename build/{ => blocks}/reply/block.json (100%)
rename build/{ => blocks}/reply/index-rtl.css (75%)
rename build/{ => blocks}/reply/index.asset.php (82%)
rename build/{ => blocks}/reply/index.css (75%)
create mode 100644 build/blocks/reply/index.js
delete mode 100644 build/editor-plugin/plugin.asset.php
delete mode 100644 build/editor-plugin/plugin.js
create mode 100644 build/embed/embed-rtl.css
create mode 100644 build/embed/embed.css
delete mode 100644 build/follow-me/index.asset.php
delete mode 100644 build/follow-me/index.js
delete mode 100644 build/follow-me/view.asset.php
delete mode 100644 build/follow-me/view.js
delete mode 100644 build/followers/index.js
delete mode 100644 build/followers/view.asset.php
delete mode 100644 build/followers/view.js
delete mode 100644 build/reactions/block.json
delete mode 100644 build/reactions/index.asset.php
delete mode 100644 build/reactions/index.js
delete mode 100644 build/reactions/style-index-rtl.css
delete mode 100644 build/reactions/style-index.css
delete mode 100644 build/reactions/view.asset.php
delete mode 100644 build/reactions/view.js
delete mode 100644 build/reply/index.js
delete mode 100644 build/reply/style-index-rtl.css
delete mode 100644 build/reply/style-index.css
create mode 100644 build/wp-admin/admin-rtl.css
create mode 100644 build/wp-admin/admin.css
create mode 100644 build/wp-admin/header-image.asset.php
create mode 100644 build/wp-admin/header-image.js
create mode 100644 build/wp-admin/post-preview-rtl.css
create mode 100644 build/wp-admin/post-preview.css
create mode 100644 build/wp-admin/script.asset.php
create mode 100644 build/wp-admin/script.js
create mode 100644 build/wp-admin/welcome-rtl.css
create mode 100644 build/wp-admin/welcome.css
create mode 100644 docs/how-to/readme.md
create mode 100644 docs/how-to/reverse-proxy.md
create mode 100644 docs/how-to/wordpress-in-a-subdir.md
create mode 100644 includes/collection/class-following.php
delete mode 100644 includes/collection/class-users.php
create mode 100644 includes/handler/class-accept.php
create mode 100644 includes/handler/class-reject.php
create mode 100644 includes/signature/class-http-message-signature.php
create mode 100644 includes/signature/class-http-signature-draft.php
create mode 100644 includes/signature/interface-http-signature.php
create mode 100644 includes/table/class-following.php
create mode 100644 includes/table/trait-actor-list-table.php
create mode 100644 includes/wp-admin/class-screen-options.php
create mode 100644 includes/wp-admin/import/class-starter-kit.php
create mode 100644 integration/class-wp-rest-cache.php
create mode 100644 src/blocks/editor-plugin/block.json
rename src/{ => blocks}/editor-plugin/plugin.js (61%)
rename src/{ => blocks}/follow-me/block.json (61%)
create mode 100644 src/blocks/follow-me/button-style.js
create mode 100644 src/blocks/follow-me/deprecation.js
create mode 100644 src/blocks/follow-me/edit.js
create mode 100644 src/blocks/follow-me/index.js
create mode 100644 src/blocks/follow-me/render.php
create mode 100644 src/blocks/follow-me/save.js
create mode 100644 src/blocks/follow-me/style.scss
create mode 100644 src/blocks/follow-me/view.js
rename src/{ => blocks}/followers/block.json (64%)
create mode 100644 src/blocks/followers/deprecations.js
create mode 100644 src/blocks/followers/edit.js
create mode 100644 src/blocks/followers/index.js
create mode 100644 src/blocks/followers/render.php
create mode 100644 src/blocks/followers/save.js
create mode 100644 src/blocks/followers/style.scss
create mode 100644 src/blocks/followers/view.js
create mode 100644 src/blocks/reactions/block.json
create mode 100644 src/blocks/reactions/deprecation.js
create mode 100644 src/blocks/reactions/edit.js
rename src/{ => blocks}/reactions/index.js (75%)
create mode 100644 src/blocks/reactions/reactions.js
create mode 100644 src/blocks/reactions/render.php
create mode 100644 src/blocks/reactions/save.js
create mode 100644 src/blocks/reactions/style.scss
create mode 100644 src/blocks/reactions/view.js
rename src/{ => blocks}/remote-reply/block.json (58%)
create mode 100644 src/blocks/remote-reply/render.php
create mode 100644 src/blocks/remote-reply/style.scss
create mode 100644 src/blocks/remote-reply/view.js
rename src/{ => blocks}/reply-intent/block.json (76%)
rename src/{ => blocks}/reply-intent/plugin.js (100%)
rename src/{ => blocks}/reply/block.json (100%)
rename src/{ => blocks}/reply/edit.js (89%)
rename src/{ => blocks}/reply/editor.scss (66%)
rename src/{ => blocks}/reply/index.js (100%)
rename src/{reply/style.css => blocks/reply/style.scss} (100%)
rename src/{ => blocks}/shared/inherit-block-fallback.js (94%)
create mode 100644 src/blocks/shared/modal/README.md
create mode 100644 src/blocks/shared/modal/index.js
create mode 100644 src/blocks/shared/modal/style.scss
rename src/{ => blocks}/shared/use-options.js (100%)
rename src/{ => blocks}/shared/use-user-options.js (74%)
delete mode 100644 src/editor-plugin/block.json
rename assets/css/activitypub-embed.css => src/embed/embed.css (100%)
delete mode 100644 src/follow-me/button-style.js
delete mode 100644 src/follow-me/edit.js
delete mode 100644 src/follow-me/follow-me.js
delete mode 100644 src/follow-me/index.js
delete mode 100644 src/follow-me/style.scss
delete mode 100644 src/follow-me/view.js
delete mode 100644 src/followers/edit.js
delete mode 100644 src/followers/followers.js
delete mode 100644 src/followers/index.js
delete mode 100644 src/followers/pagination-page.js
delete mode 100644 src/followers/pagination.js
delete mode 100644 src/followers/pagination.scss
delete mode 100644 src/followers/style.scss
delete mode 100644 src/followers/view.js
delete mode 100644 src/reactions/block.json
delete mode 100644 src/reactions/deprecation.js
delete mode 100644 src/reactions/edit.js
delete mode 100644 src/reactions/editor.scss
delete mode 100644 src/reactions/reactions.js
delete mode 100644 src/reactions/save.js
delete mode 100644 src/reactions/style.scss
delete mode 100644 src/reactions/view.js
delete mode 100644 src/remote-reply/index.js
delete mode 100644 src/remote-reply/remote-reply.js
delete mode 100644 src/remote-reply/style.scss
delete mode 100644 src/shared/dialog.js
delete mode 100644 src/shared/lightbox.scss
delete mode 100644 src/shared/use-remote-user.js
rename assets/css/activitypub-admin.css => src/wp-admin/admin.scss (92%)
rename assets/js/activitypub-header-image.js => src/wp-admin/header-image.js (91%)
rename assets/css/activitypub-post-preview.css => src/wp-admin/post-preview.css (100%)
rename assets/js/activitypub-admin.js => src/wp-admin/script.js (64%)
rename assets/css/activitypub-welcome.css => src/wp-admin/welcome.css (100%)
delete mode 100644 templates/blog-followers-list.php
create mode 100644 templates/followers-list.php
create mode 100644 templates/following-list.php
delete mode 100644 templates/user-followers-list.php
create mode 100644 tests/fixtures/http-signature-keys.json
create mode 100644 tests/includes/class-test-compat.php
create mode 100644 tests/includes/collection/class-test-following.php
create mode 100644 tests/includes/handler/class-test-accept.php
create mode 100644 tests/includes/handler/class-test-reject.php
delete mode 100644 tests/includes/rest/class-test-signature-verification.php
create mode 100644 tests/includes/wp-admin/class-test-welcome-fields.php
create mode 100644 webpack.config.js
diff --git a/.distignore b/.distignore
index 156eb54bd..a1a9e7aef 100644
--- a/.distignore
+++ b/.distignore
@@ -7,6 +7,8 @@
.github
.gitignore
.php_cs
+.prettierignore
+.prettierrc.js
.svnignore
.travis.yml
.wordpress-org
diff --git a/.editorconfig b/.editorconfig
index a541e47e7..118ef466c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,8 +1,10 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org
-
+#
# WordPress Coding Standards
# https://make.wordpress.org/core/handbook/coding-standards/
+#
+# Be sure to keep this file in sync with the .prettierrc.js file.
root = true
diff --git a/.github/changelog/1674-from-description b/.github/changelog/1674-from-description
new file mode 100644
index 000000000..86cefa97c
--- /dev/null
+++ b/.github/changelog/1674-from-description
@@ -0,0 +1,5 @@
+Significance: minor
+Type: added
+
+Post Preview now supports RTL languages.
+Embed and wp-admin styles are optimized for RTL languages.
diff --git a/.github/changelog/1900-from-description b/.github/changelog/1900-from-description
new file mode 100644
index 000000000..cd3d6ad75
--- /dev/null
+++ b/.github/changelog/1900-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Set older unfederated posts to local visibility by default.
diff --git a/.github/changelog/1907-from-description b/.github/changelog/1907-from-description
new file mode 100644
index 000000000..66fb05b55
--- /dev/null
+++ b/.github/changelog/1907-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Minor performance improvement when querying posts of various types, by avoiding double queries.
diff --git a/.github/changelog/1909-from-description b/.github/changelog/1909-from-description
new file mode 100644
index 000000000..8ecf1deea
--- /dev/null
+++ b/.github/changelog/1909-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+The following tables now more closely match the appearance of other WordPress tables and can be filtered by status.
diff --git a/.github/changelog/1913-from-description b/.github/changelog/1913-from-description
new file mode 100644
index 000000000..09417ebb4
--- /dev/null
+++ b/.github/changelog/1913-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Follower tables now look closer to what other tables in WordPress look like.
diff --git a/.github/changelog/1916-from-description b/.github/changelog/1916-from-description
new file mode 100644
index 000000000..44feb81ed
--- /dev/null
+++ b/.github/changelog/1916-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+The `following` endpoint now returns the actual list of users being followed.
diff --git a/.github/changelog/1918-from-description b/.github/changelog/1918-from-description
new file mode 100644
index 000000000..443e75fe8
--- /dev/null
+++ b/.github/changelog/1918-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fixed an issue where the number of followers shown didn’t always match the actual follower list.
diff --git a/.github/changelog/1919-from-description b/.github/changelog/1919-from-description
new file mode 100644
index 000000000..b73dcd368
--- /dev/null
+++ b/.github/changelog/1919-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Added initial support for Fediverse Starter Kits, allowing users to follow recommended accounts from a predefined list.
diff --git a/.github/changelog/1920-from-description b/.github/changelog/1920-from-description
new file mode 100644
index 000000000..2fbcf9560
--- /dev/null
+++ b/.github/changelog/1920-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Ensure that the Actor-ID is always a URL.
diff --git a/.github/changelog/1922-from-description b/.github/changelog/1922-from-description
new file mode 100644
index 000000000..d83faba44
--- /dev/null
+++ b/.github/changelog/1922-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+The featured tags endpoint is now available again for all profiles, showing the most frequently used tags by each user.
diff --git a/.github/changelog/1925-from-description b/.github/changelog/1925-from-description
new file mode 100644
index 000000000..ad58de22f
--- /dev/null
+++ b/.github/changelog/1925-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Followers and Following list tables now support Columns and Pagination screen options.
diff --git a/.github/changelog/1928-from-description b/.github/changelog/1928-from-description
new file mode 100644
index 000000000..766faf846
--- /dev/null
+++ b/.github/changelog/1928-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Removed follower dates to avoid confusion, as they may not have accurately reflected the actual follow time.
diff --git a/.github/changelog/1930-from-description b/.github/changelog/1930-from-description
new file mode 100644
index 000000000..c1a0171df
--- /dev/null
+++ b/.github/changelog/1930-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Added a first version of the Follow form, allowing users to follow other Actors by username or profile link.
diff --git a/.github/changelog/1931-from-description b/.github/changelog/1931-from-description
new file mode 100644
index 000000000..ff7134145
--- /dev/null
+++ b/.github/changelog/1931-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fixed a bug in how follow requests were accepted to ensure they work correctly.
diff --git a/.github/changelog/1932-from-description b/.github/changelog/1932-from-description
new file mode 100644
index 000000000..64240a011
--- /dev/null
+++ b/.github/changelog/1932-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fixed missing avatar class so that CSS styles are correctly applied to ActivityPub avatars on the Dashboard.
diff --git a/.github/changelog/1942-from-description b/.github/changelog/1942-from-description
new file mode 100644
index 000000000..748b09a4e
--- /dev/null
+++ b/.github/changelog/1942-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Step counts for the Welcome checklist now only take into account steps that are added in the Welcome class.
diff --git a/.github/changelog/1943-from-description b/.github/changelog/1943-from-description
new file mode 100644
index 000000000..b16182f84
--- /dev/null
+++ b/.github/changelog/1943-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Prevent WordPress from loading all admin notices twice on ActivityPub settings pages.
diff --git a/.github/changelog/1946-from-description b/.github/changelog/1946-from-description
new file mode 100644
index 000000000..0fd93ceab
--- /dev/null
+++ b/.github/changelog/1946-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Table actions are now faster by using the Custom Post Type ID instead of the remote user URI, thanks to the unified Actor Model.
diff --git a/.github/changelog/1959-from-description b/.github/changelog/1959-from-description
new file mode 100644
index 000000000..7017f8d21
--- /dev/null
+++ b/.github/changelog/1959-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Ensure that all schedulers are registered during every plugin update.
diff --git a/.github/changelog/1973-from-description b/.github/changelog/1973-from-description
new file mode 100644
index 000000000..2ab5ec1a2
--- /dev/null
+++ b/.github/changelog/1973-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fixed a PHP error that prevented the Follower overview from loading.
diff --git a/.github/changelog/1974-from-description b/.github/changelog/1974-from-description
new file mode 100644
index 000000000..dc933277d
--- /dev/null
+++ b/.github/changelog/1974-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Improved Account-Aliases handling by internally normalizing input formats.
diff --git a/.github/workflows/gardening.yml b/.github/workflows/gardening.yml
index f92e9d966..a46154cac 100644
--- a/.github/workflows/gardening.yml
+++ b/.github/workflows/gardening.yml
@@ -57,8 +57,10 @@ jobs:
{"path": "integration", "label": "[Focus] Compatibility"},
{"path": "includes/class-mailer.php", "label": "[Feature] Notifications"},
{"path": "includes/class-blocks.php", "label": "[Focus] Editor"},
+ {"path": "includes/class-cli.php", "label": "[Feature] CLI"},
{"path": "includes/collection", "label": "[Feature] Collections"},
{"path": "includes/rest", "label": "[Feature] REST API"},
+ {"path": "includes/signature", "label": "[Feature] Signature"},
{"path": "includes/wp-admin", "label": "[Feature] WP Admin"},
{"path": "includes/wp-admin/import", "label": "[Feature] Import"},
{"path": "includes/wp-admin/class-health-check.php", "label": "[Feature] Health Check"}
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index 0759414bb..8d7918afd 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -20,7 +20,7 @@ jobs:
php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
include:
- wp-version: latest
- - wp-version: '6.4'
+ - wp-version: '6.5'
php-versions: '7.2'
- wp-version: trunk
php-versions: '8.4'
diff --git a/.gitignore b/.gitignore
index 4e629a881..5e402bc67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,18 +1,17 @@
-_site
-.sass-cache
-.jekyll-cache
-.jekyll-metadata
+/build/**/*.map
/coverage/
/node_modules/
/vendor/
-package-lock.json
-composer.lock
-.DS_Store
-.cursor
+_site
.idea/
+.cursor
+.DS_Store
.php_cs.cache
.phpunit.result.cache
+.sass-cache
.vscode/settings.json
.windsurf
.windsurfrules
.wp-env.override.json
+composer.lock
+package-lock.json
diff --git a/.prettierignore b/.prettierignore
index 6e56f88ee..fbac9e95d 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,13 +1,5 @@
build
+coverage
node_modules
tests
vendor
-
-# Temporary ignores while breaking out each component.
-assets
-src/follow-me
-src/followers
-src/reactions
-src/remote-reply
-src/reply
-src/reply-intent
diff --git a/.prettierrc.js b/.prettierrc.js
index 405450da3..62cd2bd25 100644
--- a/.prettierrc.js
+++ b/.prettierrc.js
@@ -5,12 +5,6 @@ module.exports = {
printWidth: 120,
overrides: [
- {
- files: '*.json',
- options: {
- useTabs: false,
- },
- },
{
files: '*.yml',
options: {
@@ -18,11 +12,5 @@ module.exports = {
tabWidth: 2,
},
},
- {
- files: '*.md',
- options: {
- trimTrailingWhitespace: false, // Not a valid Prettier option, handled by editorconfig only
- },
- },
],
};
diff --git a/.wordpress-org/blueprints/blueprint.json b/.wordpress-org/blueprints/blueprint.json
index c337fa701..5d4e444af 100644
--- a/.wordpress-org/blueprints/blueprint.json
+++ b/.wordpress-org/blueprints/blueprint.json
@@ -1,43 +1,43 @@
{
- "landingPage": "/wp-admin/options-general.php?page=activitypub",
- "steps": [
- {
- "step": "setSiteOptions",
- "options": {
- "permalink_structure": "/%postname%/"
- }
- },
- {
- "step": "installPlugin",
- "pluginZipFile": {
- "resource": "wordpress.org/plugins",
- "slug": "activitypub"
- },
- "options": {
- "activate": true
- }
- },
- {
- "step": "login",
- "username": "admin",
- "password": "password"
- },
- {
- "step": "mkdir",
- "path": "wordpress/wp-content/mu-plugins"
- },
- {
- "step": "writeFile",
- "path": "wordpress/wp-content/mu-plugins/show-admin-notice-2.php",
- "data": "' . esc_html( 'Welcome and have fun 👋' ) . '
';\n}\n);\nadd_action('wp_ajax_dismiss_custom-admin-notice-2', function() {\ncheck_ajax_referer('custom-admin-notice-2', 'nonce');\n$user_id = get_current_user_id();\nif ( $user_id ) {\nupdate_user_option($user_id, 'dismissed_expose_blueprint_notice-2', 1, false);\nwp_send_json_success();\n} else {\nwp_send_json_error('User not found');\n}\n} );\nadd_action('admin_footer', function() {\n?>\n\n' . esc_html( 'Welcome and have fun 👋' ) . '
';\n}\n);\nadd_action('wp_ajax_dismiss_custom-admin-notice-2', function() {\ncheck_ajax_referer('custom-admin-notice-2', 'nonce');\n$user_id = get_current_user_id();\nif ( $user_id ) {\nupdate_user_option($user_id, 'dismissed_expose_blueprint_notice-2', 1, false);\nwp_send_json_success();\n} else {\nwp_send_json_error('User not found');\n}\n} );\nadd_action('admin_footer', function() {\n?>\n\n
+
+
+
+
+
+
+
+
diff --git a/.wp-env.json b/.wp-env.json
index f9016f865..352e4780e 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -1,19 +1,19 @@
{
- "core": null,
- "plugins": [ "." ],
- "env": {
- "tests": {
- "config": {
- "WP_TESTS_DOMAIN": "example.org",
- "WP_SITEURL": "http://example.org",
- "WP_HOME": "http://example.org"
- },
- "port": 80,
- "mappings": {
- "wp-content/plugins/activitypub": ".",
- "wp-content/plugins/activitypub/tests": "./tests",
- "wp-content/plugins/activitypub/coverage": "./coverage"
- }
- }
- }
+ "core": null,
+ "plugins": [ "." ],
+ "env": {
+ "tests": {
+ "config": {
+ "WP_TESTS_DOMAIN": "example.org",
+ "WP_SITEURL": "http://example.org",
+ "WP_HOME": "http://example.org"
+ },
+ "port": 80,
+ "mappings": {
+ "wp-content/plugins/activitypub": ".",
+ "wp-content/plugins/activitypub/tests": "./tests",
+ "wp-content/plugins/activitypub/coverage": "./coverage"
+ }
+ }
+ }
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3475bf5bc..a23ec8f6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,115 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [7.0.1] - 2025-07-10
+### Fixed
+- When deleting interactions for cleaned up actors, we use the actor's URL again to retrieve their information instead of our internal ID. [#1915]
+
+## [7.0.0] - 2025-07-09
+### Added
+- Added basic support for handling remote rejections of follow requests. [#1865]
+- Added basic support for RFC-9421 style signatures for incoming activities. [#1849]
+- Added initial Following support for Actors, hidden for now until plugins add support. [#1866]
+- Added missing "Advanced Settings" details to Site Health debug information. [#1846]
+- Added option to auto-approve reactions like likes and reposts. [#1847]
+- Added support for namespaced attributes and the dcterms:subject field (FEP-b2b8), as a first step toward phasing out summary-based content warnings. [#1893]
+- Added support for the WP Rest Cache plugin to help with caching REST API responses. [#1630]
+- Documented support for FEP-844e. [#1868]
+- Optional support for RFC-9421 style signatures for outgoing activities, including retry with Draft-Cavage-style signature. [#1858]
+- Reactions block now supports customizing colors, borders, box-shadows, and typography. [#1826]
+- Support for sending follow requests to remote actors is now in place, including outbox delivery and status updates—UI integration will follow later. [#1839]
+
+### Changed
+- Comment feeds now show only comments by default, with a new `type` filter (e.g., `like`, `all`) to customize which reactions appear. [#1877]
+- Consistent naming of Blog user in Block settings. [#1862]
+- hs2019 signatures for incoming REST API requests now have their algorithm determined based on their public key. [#1848]
+- Likes, comments, and reposts from the Fediverse now require either a name or `preferredUsername` to be set when the Discussion option `require_name_email` is set to true. It falls back to "Anonymous", if not. [#1811]
+- Management of public/private keys for Actors now lives in the Actors collection, in preparation for Signature improvements down the line. [#1832]
+- Notification emails for new reactions received from the Fediverse now link to the moderation page instead of the edit page, preventing errors and making comment management smoother. [#1887]
+- Plugins now have full control over which Settings tabs are shown in Settings > Activitypub. [#1806]
+- Reworked follower structure to simplify handling and enable reuse for following mechanism. [#1759]
+- Screen options in the Activitypub settings page are now filterable. [#1802]
+- Setting the blog identifier to empty will no longer trigger an error message about it being the same as an existing user name. [#1805]
+- Step completion tracking in the Welcome tab now even works when the number of steps gets reduced. [#1809]
+- The image attachment setting is no longer saved to the database if it matches the default value. [#1821]
+- The welcome page now links to the correct profile when Blog Only mode was selected in the profile mode step. [#1807]
+- Unified retrieval of comment avatars and re-used core filters to give access to third-part plugins. [#1812]
+
+### Fixed
+- Allow interaction redirect URLs that contain an ampersand. [#1819]
+- Comments received from the Fediverse no longer show an Edit link in the comment list, despite not being editable. [#1895]
+- Fixed an issue where links to remote likes and boosts could open raw JSON instead of a proper page. [#1857]
+- Fixed a potential error when getting an Activitypub ID based on a user ID. [#1889]
+- HTTP signatures using the hs2019 algorithm now get accepted without error. [#1814]
+- Improved compatibility with older follower data. [#1841]
+- Inbox requests that are missing an `algorithm` parameter in their signature no longer create a PHP warning. [#1803]
+- Interaction attempts that pass a webfinger ID instead of a URL will work again. [#1834]
+- Names containing HTML entities now get displayed correctly in the Reactions block's list of users. [#1810]
+- Prevent storage of empty or default post meta values. [#1829]
+- The amount of avatars shown in the Reactions block no longer depends on the amount of likes, but is comment type agnostic. [#1835]
+- The command-line interface extension, accidentally removed in a recent cleanup, has been restored. [#1878]
+- The image attachment setting now correctly respects a value of 0, instead of falling back to the default. [#1822]
+- The Welcome screen now loads with proper styling when shown as a fallback. [#1820]
+- Using categories as hashtags has been removed to prevent conflicts with tags of the same name. [#1873]
+- When verifying signatures on incoming requests, the digest header now gets checked as expected. [#1837]
+
+## [6.0.2] - 2025-06-11
+### Changed
+- Reactions button color is now a little more theme agnostic. [#1795]
+
+### Fixed
+- "Account Aliases" setting in user profiles get saved correctly again and no longer return empty. [#1798]
+- Blocks updated in 6.0.0 are back to not showing up in feeds and federated posts. [#1794]
+- Webfinger data from Pleroma instances no longer creates unexpected mention markup. [#1799]
+
+## [6.0.1] - 2025-06-09
+### Fixed
+- Added fallback for follower list during migration to new database schema. [#1781]
+- Avoids the button block breaking for users that don't have the `unfiltered_html` capability.
+ Blog users now get their correct post count displayed in the Editor and the front-end. [#1777]
+- Improved follower migration: scheduler now more reliable and won't stop too early. [#1778]
+- Update the Stream Connector integration to align with the new database schema. [#1787]
+
+## [6.0.0] - 2025-06-05
+### Added
+- Enhanced markup of the "follow me" block, for a better Webmention and IndieWeb support. [#1771]
+- The actor of the replied-to post is now included in cc or to based on the post's visibility. [#1711]
+
+### Changed
+- "Reply on the Fediverse" now uses the Interactivity API for display on the frontend. [#1721]
+- Bumped minimum required WordPress version to 6.5. [#1703]
+- Default avatar and error handling for the reactions popover list. [#1719]
+- Ensured that publishing a new blog post always sends a Create to the Fediverse. [#1713]
+- Followers block has an updated design, new block variations, and uses the Interactivity API for display on the frontend. [#1747]
+- Follow Me and Followers blocks can now list any user that is Activitypub-enabled, even if they have the Subscriber role. [#1754]
+- Likes and Reposts for comments to a post are no longer attributed to the post itself. [#1735]
+- New system to manage followers and followings more consistently using a unified actor type. [#1726]
+- Re-enabled HTML support in excerpts and summaries to properly display hashtags and @-replies, now that Mastodon supports it. [#1731]
+- Refactored to use CSS for effects instead of JavaScript, simplifying the code. [#1718]
+- Refine the plugin’s handling and storage of remote actor data. [#1751]
+- The Follow Me block now uses the latest Block Editor technology for display on the frontend. [#1691]
+- The Reactions block now uses the latest Block Editor technology for display on the frontend. [#1722]
+
+### Removed
+- Cleaned up the codebase and removed deprecated functions. [#1723]
+
+### Fixed
+- Added forward compatibility for Editor Controls, fixing deprecated warnings in the Editor. [#1748]
+- Avoid type mismatch when updating `activitypub_content_warning` meta values. [#1766]
+- Default number of attachments now works correctly in block editor. [#1765]
+- Fixed a bug in Site Health that caused a PHP warning and missing details for the WebFinger check. [#1733]
+- Fixes a bug in WordPress 6.5 where the plugin settings in the Editor would fail to render, due to a backwards compatibility break. [#1760]
+- Improved automated setup process for the Surge caching plugin. [#1724]
+- Improved excerpt handling by removing shortcodes from summaries. [#1730]
+
+## [5.9.2] - 2025-05-16
+### Fixed
+- Titles added through a Heading block in the Reactions block now stay properly hidden when there are no reactions. [#1709]
+
+## [5.9.1] - 2025-05-15
+### Fixed
+- Fixed a bug where Reaction blocks without modified titles did not get displayed correctly. [#1705]
+
## [5.9.0] - 2025-05-14
### Added
- ActivityPub embeds now support audios, videos, and up to 4 images. [#1645]
@@ -37,7 +146,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Use `Audio` and `Video` type for Attachments, instead of the very generic `Document` type. [#1486]
### Deprecated
-- Deprecated `rest_activitypub_outbox_query` filter in favor of `activitypub_rest_outbox_query`.
+- Deprecated `rest_activitypub_outbox_query` filter in favor of `activitypub_rest_outbox_query`.
Deprecated `activitypub_outbox_post` action in favor of `activitypub_rest_outbox_post`. [#1628]
### Fixed
@@ -1212,6 +1321,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- initial
+[7.0.1]: https://github.com/Automattic/wordpress-activitypub/compare/7.0.0...7.0.1
+[7.0.0]: https://github.com/Automattic/wordpress-activitypub/compare/6.0.2...7.0.0
+[6.0.2]: https://github.com/Automattic/wordpress-activitypub/compare/6.0.1...6.0.2
+[6.0.1]: https://github.com/Automattic/wordpress-activitypub/compare/6.0.0...6.0.1
+[6.0.0]: https://github.com/Automattic/wordpress-activitypub/compare/5.9.2...6.0.0
+[5.9.2]: https://github.com/Automattic/wordpress-activitypub/compare/5.9.1...5.9.2
+[5.9.1]: https://github.com/Automattic/wordpress-activitypub/compare/5.9.0...5.9.1
[5.9.0]: https://github.com/Automattic/wordpress-activitypub/compare/5.8.0...5.9.0
[5.8.0]: https://github.com/Automattic/wordpress-activitypub/compare/5.7.0...5.8.0
[5.7.0]: https://github.com/Automattic/wordpress-activitypub/compare/5.6.1...5.7.0
diff --git a/FEDERATION.md b/FEDERATION.md
index 243459eb5..4db647899 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -5,8 +5,8 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio
## Supported federation protocols and standards
- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
-- [WebFinger](https://swicg.github.io/activitypub-http-signature/)
-- [HTTP Signatures](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/)
+- [WebFinger](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/)
+- [HTTP Signatures](https://swicg.github.io/activitypub-http-signature/)
- [NodeInfo](https://nodeinfo.diaspora.software/)
## Supported FEPs
@@ -19,6 +19,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio
- [FEP-fb2a: Actor metadata](https://codeberg.org/fediverse/fep/src/branch/main/fep/fb2a/fep-fb2a.md)
- [FEP-b2b8: Long-form Text](https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md)
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
+- [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md)
Partially supported FEPs
diff --git a/activitypub.php b/activitypub.php
index d1ee2845f..2d521f2e9 100644
--- a/activitypub.php
+++ b/activitypub.php
@@ -3,7 +3,7 @@
* Plugin Name: ActivityPub
* Plugin URI: https://github.com/Automattic/wordpress-activitypub
* Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.
- * Version: 5.9.0
+ * Version: 7.0.1
* Author: Matthias Pfefferle & Automattic
* Author URI: https://automattic.com/
* License: MIT
@@ -19,7 +19,7 @@
use WP_CLI;
-\define( 'ACTIVITYPUB_PLUGIN_VERSION', '5.9.0' );
+\define( 'ACTIVITYPUB_PLUGIN_VERSION', '7.0.1' );
// Plugin related constants.
\define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
@@ -96,8 +96,10 @@ function plugin_init() {
* Initialize plugin admin.
*/
function plugin_admin_init() {
- // Menus are registered before `admin_init`, because of course they are.
+ // Screen Options and Menus are set before `admin_init`.
+ \add_filter( 'init', array( __NAMESPACE__ . '\WP_Admin\Screen_Options', 'init' ) );
\add_action( 'admin_menu', array( __NAMESPACE__ . '\WP_Admin\Menu', 'admin_menu' ) );
+
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Admin', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Health_Check', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) );
@@ -151,50 +153,7 @@ function activation_redirect( $plugin ) {
)
);
-
-/**
- * `get_plugin_data` wrapper.
- *
- * @deprecated 4.2.0 Use `get_plugin_data` instead.
- *
- * @param array $default_headers Optional. The default plugin headers. Default empty array.
- * @return array The plugin metadata array.
- */
-function get_plugin_meta( $default_headers = array() ) {
- _deprecated_function( __FUNCTION__, '4.2.0', 'get_plugin_data' );
-
- if ( ! $default_headers ) {
- $default_headers = array(
- 'Name' => 'Plugin Name',
- 'PluginURI' => 'Plugin URI',
- 'Version' => 'Version',
- 'Description' => 'Description',
- 'Author' => 'Author',
- 'AuthorURI' => 'Author URI',
- 'TextDomain' => 'Text Domain',
- 'DomainPath' => 'Domain Path',
- 'Network' => 'Network',
- 'RequiresWP' => 'Requires at least',
- 'RequiresPHP' => 'Requires PHP',
- 'UpdateURI' => 'Update URI',
- );
- }
-
- return \get_file_data( __FILE__, $default_headers, 'plugin' );
-}
-
-/**
- * Plugin Version Number used for caching.
- *
- * @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
- */
-function get_plugin_version() {
- _deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
-
- return ACTIVITYPUB_PLUGIN_VERSION;
-}
-
-// Check for CLI env, to add the CLI commands.
+// Check for CLI env, to add the CLI commands.Add commentMore actions.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command(
'activitypub',
diff --git a/build/editor-plugin/block.json b/build/blocks/editor-plugin/block.json
similarity index 100%
rename from build/editor-plugin/block.json
rename to build/blocks/editor-plugin/block.json
diff --git a/build/blocks/editor-plugin/plugin.asset.php b/build/blocks/editor-plugin/plugin.asset.php
new file mode 100644
index 000000000..276924f72
--- /dev/null
+++ b/build/blocks/editor-plugin/plugin.asset.php
@@ -0,0 +1 @@
+ array('react', 'wp-components', 'wp-core-data', 'wp-data', 'wp-edit-post', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives', 'wp-url'), 'version' => '59c90d6c8ab5e740f5a1');
diff --git a/build/blocks/editor-plugin/plugin.js b/build/blocks/editor-plugin/plugin.js
new file mode 100644
index 000000000..83a5cd37e
--- /dev/null
+++ b/build/blocks/editor-plugin/plugin.js
@@ -0,0 +1 @@
+(()=>{"use strict";var e={20:(e,t,i)=>{var n=i(609),a=Symbol.for("react.element"),o=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),l=n.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,r={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,i){var n,c={},s=null,p=null;for(n in void 0!==i&&(s=""+i),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(p=t.ref),t)o.call(t,n)&&!r.hasOwnProperty(n)&&(c[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps)void 0===c[n]&&(c[n]=t[n]);return{$$typeof:a,type:e,key:s,ref:p,props:c,_owner:l.current}}},609:e=>{e.exports=window.React},848:(e,t,i)=>{e.exports=i(20)}},t={};function i(n){var a=t[n];if(void 0!==a)return a.exports;var o=t[n]={exports:{}};return e[n](o,o.exports,i),o.exports}var n=i(609);const a=window.wp.editor,o=window.wp.editPost,l=window.wp.plugins,r=window.wp.components,c=window.wp.element,s=(0,c.forwardRef)((function({icon:e,size:t=24,...i},n){return(0,c.cloneElement)(e,{width:t,height:t,...i,ref:n})})),p=window.wp.primitives;var u=i(848);const v=(0,u.jsx)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,u.jsx)(p.Path,{d:"M12 3.3c-4.8 0-8.8 3.9-8.8 8.8 0 4.8 3.9 8.8 8.8 8.8 4.8 0 8.8-3.9 8.8-8.8s-4-8.8-8.8-8.8zm6.5 5.5h-2.6C15.4 7.3 14.8 6 14 5c2 .6 3.6 2 4.5 3.8zm.7 3.2c0 .6-.1 1.2-.2 1.8h-2.9c.1-.6.1-1.2.1-1.8s-.1-1.2-.1-1.8H19c.2.6.2 1.2.2 1.8zM12 18.7c-1-.7-1.8-1.9-2.3-3.5h4.6c-.5 1.6-1.3 2.9-2.3 3.5zm-2.6-4.9c-.1-.6-.1-1.1-.1-1.8 0-.6.1-1.2.1-1.8h5.2c.1.6.1 1.1.1 1.8s-.1 1.2-.1 1.8H9.4zM4.8 12c0-.6.1-1.2.2-1.8h2.9c-.1.6-.1 1.2-.1 1.8 0 .6.1 1.2.1 1.8H5c-.2-.6-.2-1.2-.2-1.8zM12 5.3c1 .7 1.8 1.9 2.3 3.5H9.7c.5-1.6 1.3-2.9 2.3-3.5zM10 5c-.8 1-1.4 2.3-1.8 3.8H5.5C6.4 7 8 5.6 10 5zM5.5 15.3h2.6c.4 1.5 1 2.8 1.8 3.7-1.8-.6-3.5-2-4.4-3.7zM14 19c.8-1 1.4-2.2 1.8-3.7h2.6C17.6 17 16 18.4 14 19z"})}),w=(0,u.jsx)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,u.jsx)(p.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),_=(0,u.jsx)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,u.jsx)(p.Path,{d:"M19.5 4.5h-7V6h4.44l-5.97 5.97 1.06 1.06L18 7.06v4.44h1.5v-7Zm-13 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3H17v3a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h3V5.5h-3Z"})}),d=window.wp.data,m=window.wp.coreData,h=window.wp.url,b=window.wp.i18n;(0,l.registerPlugin)("activitypub-editor-plugin",{render:()=>{const e=(0,d.useSelect)((e=>e(a.store).getCurrentPostType()),[]),[t,i]=(0,m.useEntityProp)("postType",e,"meta");if("wp_block"===e)return null;const l=(0,n.createElement)(p.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24"},(0,n.createElement)(p.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5A6.5 6.5 0 0 1 6.93 7.931l9.139 9.138A6.473 6.473 0 0 1 12 18.5Zm5.123-2.498a6.5 6.5 0 0 0-9.124-9.124l9.124 9.124ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Z"})),c={verticalAlign:"middle",gap:"4px",justifyContent:"start",display:"inline-flex",alignItems:"center"},u=(e,t,i)=>(0,n.createElement)(r.Tooltip,{text:i},(0,n.createElement)(r.__experimentalText,{style:c},(0,n.createElement)(s,{icon:e}),t)),_=a.PluginDocumentSettingPanel||o.PluginDocumentSettingPanel;return(0,n.createElement)(_,{name:"activitypub",className:"block-editor-block-inspector",title:(0,b.__)("Fediverse ⁂","activitypub")},(0,n.createElement)(r.TextControl,{label:(0,b.__)("Content Warning","activitypub"),value:t?.activitypub_content_warning,onChange:e=>{i({...t,activitypub_content_warning:e})},placeholder:(0,b.__)("Optional content warning","activitypub"),help:(0,b.__)("Content warnings do not change the content on your site, only in the fediverse.","activitypub"),__next40pxDefaultSize:!0,__nextHasNoMarginBottom:!0}),(0,n.createElement)(r.RangeControl,{label:(0,b.__)("Maximum Image Attachments","activitypub"),value:t?.activitypub_max_image_attachments,onChange:e=>{i({...t,activitypub_max_image_attachments:e})},min:0,max:10,help:(0,b.__)("Maximum number of image attachments to include when sharing to the fediverse.","activitypub"),__next40pxDefaultSize:!0,__nextHasNoMarginBottom:!0}),(0,n.createElement)(r.RadioControl,{label:(0,b.__)("Visibility","activitypub"),help:(0,b.__)("This adjusts the visibility of a post in the fediverse, but note that it won't affect how the post appears on the blog.","activitypub"),selected:t?.activitypub_content_visibility||"public",options:[{label:u(v,(0,b.__)("Public","activitypub"),(0,b.__)("Post will be visible to everyone and appear in public timelines.","activitypub")),value:"public"},{label:u(w,(0,b.__)("Quiet public","activitypub"),(0,b.__)("Post will be visible to everyone but will not appear in public timelines.","activitypub")),value:"quiet_public"},{label:u(l,(0,b.__)("Do not federate","activitypub"),(0,b.__)("Post will not be shared to the Fediverse.","activitypub")),value:"local"}],onChange:e=>{i({...t,activitypub_content_visibility:e})},className:"activitypub-visibility"}))}}),(0,l.registerPlugin)("activitypub-editor-preview",{render:()=>{const e=(0,d.useSelect)((e=>e(a.store).getCurrentPost().status),[]);return(0,n.createElement)(n.Fragment,null,a.PluginPreviewMenuItem?(0,n.createElement)(a.PluginPreviewMenuItem,{onClick:()=>{const e=(0,d.select)(a.store).getEditedPostPreviewLink(),t=(0,h.addQueryArgs)(e,{activitypub:"true"});window.open(t,"_blank")},icon:_,disabled:"auto-draft"===e},(0,b.__)("Fediverse preview ⁂","activitypub")):null)}})})();
\ No newline at end of file
diff --git a/build/follow-me/block.json b/build/blocks/follow-me/block.json
similarity index 61%
rename from build/follow-me/block.json
rename to build/blocks/follow-me/block.json
index e799fbb58..36b8965b3 100644
--- a/build/follow-me/block.json
+++ b/build/blocks/follow-me/block.json
@@ -2,14 +2,20 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/follow-me",
"apiVersion": 3,
- "version": "1.0.0",
+ "version": "2.2.0",
"title": "Follow me on the Fediverse",
"category": "widgets",
"description": "Display your Fediverse profile so that visitors can follow you.",
"textdomain": "activitypub",
"icon": "groups",
+ "example": {
+ "attributes": {
+ "className": "is-style-default"
+ }
+ },
"supports": {
"html": false,
+ "interactivity": true,
"color": {
"gradients": true,
"link": true,
@@ -25,34 +31,38 @@
"color": true,
"style": true
},
+ "shadow": true,
"typography": {
"fontSize": true,
"__experimentalDefaultControls": {
"fontSize": true
}
+ },
+ "innerBlocks": {
+ "allowedBlocks": [
+ "core/button"
+ ]
}
},
- "attributes": {
- "selectedUser": {
- "type": "string",
- "default": "site"
+ "styles": [
+ {
+ "name": "default",
+ "label": "Default",
+ "isDefault": true
},
- "buttonOnly": {
- "type": "boolean",
- "default": false
+ {
+ "name": "button-only",
+ "label": "Button"
},
- "buttonText": {
- "type": "string",
- "default": "Follow"
- },
- "buttonSize": {
+ {
+ "name": "profile",
+ "label": "Profile"
+ }
+ ],
+ "attributes": {
+ "selectedUser": {
"type": "string",
- "default": "default",
- "enum": [
- "small",
- "default",
- "compact"
- ]
+ "default": "blog"
}
},
"usesContext": [
@@ -60,9 +70,8 @@
"postId"
],
"editorScript": "file:./index.js",
- "viewScript": "file:./view.js",
- "style": [
- "file:./style-view.css",
- "wp-components"
- ]
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
+ "style": "file:./style-index.css",
+ "render": "file:./render.php"
}
\ No newline at end of file
diff --git a/build/blocks/follow-me/index.asset.php b/build/blocks/follow-me/index.asset.php
new file mode 100644
index 000000000..01ddc09b2
--- /dev/null
+++ b/build/blocks/follow-me/index.asset.php
@@ -0,0 +1 @@
+ array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '2f132bae6907da90c624');
diff --git a/build/blocks/follow-me/index.js b/build/blocks/follow-me/index.js
new file mode 100644
index 000000000..ddb0c9341
--- /dev/null
+++ b/build/blocks/follow-me/index.js
@@ -0,0 +1,2 @@
+(()=>{var e,t={20:(e,t,r)=>{"use strict";var o=r(609),n=Symbol.for("react.element"),a=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),l=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,s={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var o,i={},c=null,u=null;for(o in void 0!==r&&(c=""+r),void 0!==t.key&&(c=""+t.key),void 0!==t.ref&&(u=t.ref),t)a.call(t,o)&&!s.hasOwnProperty(o)&&(i[o]=t[o]);if(e&&e.defaultProps)for(o in t=e.defaultProps)void 0===i[o]&&(i[o]=t[o]);return{$$typeof:n,type:e,key:c,ref:u,props:i,_owner:l.current}}},28:(e,t,r)=>{"use strict";const o=window.wp.blocks,n=window.wp.primitives;var a=r(848);const l=(0,a.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,a.jsx)(n.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})});var s=r(609),i=r(942),c=r.n(i);const u=window.wp.blockEditor,p=window.wp.i18n,d={html:!1,color:{gradients:!0,link:!0,__experimentalDefaultControls:{background:!0,text:!0,link:!0}},__experimentalBorder:{radius:!0,width:!0,color:!0,style:!0},typography:{fontSize:!0,__experimentalDefaultControls:{fontSize:!0}}},f=d;function v({buttonOnly:e=!1,className:t="",...r}){return r.className=c()(t,e?"is-style-button-only":"is-style-default"),r}const b={attributes:{buttonOnly:{type:"boolean",default:!1},buttonText:{type:"string",default:"Follow"},selectedUser:{type:"string",default:"blog"}},supports:d,isEligible:({buttonText:e,buttonOnly:t})=>!!e||!!t,migrate({buttonText:e,...t}){const r=(0,o.createBlock)("core/button",{text:e});return[v(t),[r]]}},m={attributes:{selectedUser:{type:"string",default:"blog"},buttonOnly:{type:"boolean",default:!1}},supports:f,isEligible:({buttonOnly:e})=>!!e,migrate:v,save(){const e=u.useBlockProps.save(),t=u.useInnerBlocksProps.save(e);return(0,s.createElement)("div",{...t})}},y=[{attributes:{selectedUser:{type:"string",default:"blog"}},supports:f,isEligible:(e,t)=>1===t.length&&"button"===t[0].attributes.tagName,migrate(e,t){var r;const{tagName:n,...a}=t[0].attributes,l=null!==(r=t[0].originalContent.replace(/<[^>]*>/g,""))&&void 0!==r?r:(0,p.__)("Follow","activitypub");return[e,[(0,o.createBlock)("core/button",{...a,text:l})]]},save(){const e=u.useBlockProps.save(),t=u.useInnerBlocksProps.save(e);return(0,s.createElement)("div",{...t})}},m,b],w=window.wp.apiFetch;var h=r.n(w);const g=window.wp.data,_=window.wp.coreData,E=window.wp.components,k=window.wp.element;function x(){return window._activityPubOptions||{}}function O({name:e}){const{enabled:t}=x(),r=t?.blog?"":(0,p.__)("It will be empty in other non-author contexts.","activitypub"),o=(0,p.sprintf)(/* translators: %1$s: block name, %2$s: extra information for non-author context */ /* translators: %1$s: block name, %2$s: extra information for non-author context */
+(0,p.__)("This %1$s block will adapt to the page it is on, displaying the user profile associated with a post author (in a loop) or a user archive. %2$s","activitypub"),e,r).trim();return(0,s.createElement)(E.Card,null,(0,s.createElement)(E.CardBody,null,(0,k.createInterpolateElement)(o,{strong:(0,s.createElement)("strong",null)})))}const S={avatar:"https://secure.gravatar.com/avatar/default?s=120",webfinger:"@well@hello.dolly",name:(0,p.__)("Hello Dolly Fan Account","activitypub"),url:"#",image:{url:""},summary:""};function B(e){if(!e)return S;const t={...S,...e};return t.avatar=t?.icon?.url,t.webfinger&&!t.webfinger.startsWith("@")&&(t.webfinger="@"+t.webfinger),t}function N({profile:e,className:t,innerBlocksProps:r}){const{webfinger:o,avatar:n,name:a,image:l,summary:i,followersCount:c,postsCount:u}=e,d=t&&t.includes("is-style-button-only"),f={posts:u||0,followers:c||0};return(0,s.createElement)("div",{className:"activitypub-profile"},!d&&l?.url&&(0,s.createElement)("div",{className:"activitypub-profile__header",style:{backgroundImage:`url(${l.url})`}}),(0,s.createElement)("div",{className:"activitypub-profile__body"},!d&&(0,s.createElement)("img",{className:"activitypub-profile__avatar",src:n,alt:a}),(0,s.createElement)("div",{className:"activitypub-profile__content"},!d&&(0,s.createElement)("div",{className:"activitypub-profile__info"},(0,s.createElement)("div",{className:"activitypub-profile__name"},a),(0,s.createElement)("div",{className:"activitypub-profile__handle"},o)),(0,s.createElement)("div",{...r}),!d&&(0,s.createElement)("div",{className:"activitypub-profile__bio",dangerouslySetInnerHTML:{__html:i}}),!d&&(0,s.createElement)("div",{className:"activitypub-profile__stats"},Object.entries(f).map((([e,t])=>(0,s.createElement)("div",{key:e},(0,s.createElement)("strong",null,t)," ","posts"===e?(0,p._n)("post","posts",t,"activitypub"):"followers"===e?(0,p._n)("follower","followers",t,"activitypub"):(0,p._n)("following","following",t,"activitypub"))))))))}const P=JSON.parse('{"$schema":"https://schemas.wp.org/trunk/block.json","name":"activitypub/follow-me","apiVersion":3,"version":"2.2.0","title":"Follow me on the Fediverse","category":"widgets","description":"Display your Fediverse profile so that visitors can follow you.","textdomain":"activitypub","icon":"groups","example":{"attributes":{"className":"is-style-default"}},"supports":{"html":false,"interactivity":true,"color":{"gradients":true,"link":true,"__experimentalDefaultControls":{"background":true,"text":true,"link":true}},"__experimentalBorder":{"radius":true,"width":true,"color":true,"style":true},"shadow":true,"typography":{"fontSize":true,"__experimentalDefaultControls":{"fontSize":true}},"innerBlocks":{"allowedBlocks":["core/button"]}},"styles":[{"name":"default","label":"Default","isDefault":true},{"name":"button-only","label":"Button"},{"name":"profile","label":"Profile"}],"attributes":{"selectedUser":{"type":"string","default":"blog"}},"usesContext":["postType","postId"],"editorScript":"file:./index.js","viewScriptModule":"file:./view.js","viewScript":"wp-api-fetch","style":"file:./style-index.css","render":"file:./render.php"}');(0,o.registerBlockType)(P,{deprecated:y,edit:function({attributes:e,setAttributes:t,context:{postType:r,postId:o}}){const n=(0,u.useBlockProps)({className:"activitypub-follow-me-block-wrapper"}),a=function({withInherit:e=!1}){const{enabled:t}=x(),r=t?.users?(0,g.useSelect)((e=>e("core").getUsers({capabilities:"activitypub"})),[]):[];return(0,k.useMemo)((()=>{if(!r)return[];const o=[];return t?.blog&&o.push({label:(0,p.__)("Blog","activitypub"),value:"blog"}),e&&t?.users&&o.push({label:(0,p.__)("Dynamic User","activitypub"),value:"inherit"}),r.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),o)}),[r])}({withInherit:!0}),{namespace:l}=x(),{selectedUser:i,className:c="is-style-default"}=e,d="inherit"===i,[f,v]=(0,k.useState)(B(S)),b="blog"===i?0:i,m=[["core/button",{text:(0,p.__)("Follow","activitypub")}]],y=(0,u.useInnerBlocksProps)({},{allowedBlocks:["core/button"],template:m,templateLock:!1,renderAppender:!1}),w=(0,g.useSelect)((e=>{const{getEditedEntityRecord:t}=e(_.store),n=t("postType",r,o)?.author;return null!=n?n:null}),[r,o]);return(0,k.useEffect)((()=>{if(d&&!w)return;const e=d?w:b;(function(e){const{namespace:t}=x(),r={headers:{Accept:"application/activity+json"},path:`/${t}/actors/${e}`};return h()(r)})(e).then((t=>{if(v(B(t)),t.followers)try{const{pathname:e}=new URL(t.followers);h()({path:e.replace("wp-json/","")}).then((({totalItems:e=0})=>{v((t=>({...t,followersCount:e})))})).catch((()=>{}))}catch(e){}e?h()({path:`/wp/v2/users/${e}/?context=activitypub`}).then((({post_count:e})=>{v((t=>({...t,postsCount:e})))})).catch((()=>{})):h()({path:"/wp/v2/posts",method:"HEAD",parse:!1}).then((e=>{const t=e.headers.get("X-WP-Total");v((e=>({...e,postsCount:t})))})).catch((()=>{}))})).catch((()=>{}))}),[b,w,d]),(0,k.useEffect)((()=>{a.length&&(a.find((({value:e})=>e===i))||t({selectedUser:a[0].value}))}),[i,a]),(0,s.createElement)("div",{...n},(0,s.createElement)(u.InspectorControls,{key:"activitypub-follow-me"},a.length>1&&(0,s.createElement)(E.PanelBody,{title:(0,p.__)("Follow Me Options","activitypub")},(0,s.createElement)(E.SelectControl,{label:(0,p.__)("Select User","activitypub"),value:e.selectedUser,options:a,onChange:e=>t({selectedUser:e}),__next40pxDefaultSize:!0,__nextHasNoMarginBottom:!0}))),d&&!w?(0,s.createElement)(O,{name:(0,p.__)("Follow Me","activitypub")}):(0,s.createElement)(N,{profile:f,className:c,innerBlocksProps:y}))},icon:l,save:function(){const e=u.useBlockProps.save(),t=u.useInnerBlocksProps.save(e);return(0,s.createElement)("div",{...t})}})},609:e=>{"use strict";e.exports=window.React},848:(e,t,r)=>{"use strict";e.exports=r(20)},942:(e,t)=>{var r;!function(){"use strict";var o={}.hasOwnProperty;function n(){for(var e="",t=0;t{if(!r){var l=1/0;for(u=0;u=a)&&Object.keys(o.O).every((e=>o.O[e](r[i])))?r.splice(i--,1):(s=!1,a0&&e[u-1][2]>a;u--)e[u]=e[u-1];e[u]=[r,n,a]},o.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={759:0,975:0};o.O.j=t=>0===e[t];var t=(t,r)=>{var n,a,[l,s,i]=r,c=0;if(l.some((t=>0!==e[t]))){for(n in s)o.o(s,n)&&(o.m[n]=s[n]);if(i)var u=i(o)}for(t&&t(r);co(28)));n=o.O(n)})();
\ No newline at end of file
diff --git a/build/blocks/follow-me/render.php b/build/blocks/follow-me/render.php
new file mode 100644
index 000000000..df7f3ec4c
--- /dev/null
+++ b/build/blocks/follow-me/render.php
@@ -0,0 +1,244 @@
+ ACTIVITYPUB_REST_NAMESPACE,
+ 'i18n' => array(
+ 'copy' => __( 'Copy', 'activitypub' ),
+ 'copied' => __( 'Copied!', 'activitypub' ),
+ 'emptyProfileError' => __( 'Please enter a profile URL or handle.', 'activitypub' ),
+ 'genericError' => __( 'An error occurred. Please try again.', 'activitypub' ),
+ 'invalidProfileError' => __( 'Please enter a valid profile URL or handle.', 'activitypub' ),
+ ),
+ )
+);
+
+// Add the block wrapper attributes.
+$wrapper_attributes = array(
+ 'id' => $block_id,
+ 'class' => 'activitypub-follow-me-block-wrapper',
+ 'data-wp-interactive' => 'activitypub/follow-me',
+ 'data-wp-init' => 'callbacks.initButtonStyles',
+);
+if ( isset( $attributes['buttonOnly'] ) ) {
+ $wrapper_attributes['class'] .= ' is-style-button-only';
+}
+
+$wrapper_context = wp_interactivity_data_wp_context(
+ array(
+ 'backgroundColor' => $background_color,
+ 'blockId' => $block_id,
+ 'buttonStyle' => $button_style,
+ 'copyButtonText' => __( 'Copy', 'activitypub' ),
+ 'errorMessage' => '',
+ 'isError' => false,
+ 'isLoading' => false,
+ 'modal' => array( 'isOpen' => false ),
+ 'remoteProfile' => '',
+ 'userId' => $user_id,
+ 'webfinger' => '@' . $actor->get_webfinger(),
+ )
+);
+
+if ( empty( $content ) ) {
+ $button_text = $attributes['buttonText'] ?? __( 'Follow', 'activitypub' );
+ $content = '';
+} else {
+ $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
+}
+
+$content = Blocks::add_directions(
+ $content,
+ array( 'class_name' => 'wp-element-button' ),
+ array(
+ 'data-wp-on--click' => 'actions.toggleModal',
+ 'data-wp-on-async--keydown' => 'actions.onKeydown',
+ 'data-wp-bind--aria-expanded' => 'context.modal.isOpen',
+ 'aria-label' => __( 'Follow me on the Fediverse', 'activitypub' ),
+ 'aria-haspopup' => 'dialog',
+ 'aria-controls' => 'modal-heading',
+ 'role' => 'button',
+ 'tabindex' => '0',
+ )
+);
+
+$header_image = $actor->get_image();
+$has_header = ! empty( $header_image['url'] ) && str_contains( $attributes['className'] ?? '', 'is-style-profile' );
+
+$stats = array(
+ 'posts' => $user_id ? count_user_posts( $user_id, 'post', true ) : (int) wp_count_posts()->publish,
+ 'followers' => Followers::count_followers( $user_id ),
+);
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_summary() ) : ?>
+
+ get_summary() ); ?>
+
+
+
+
+
+
+ ' . esc_html( number_format_i18n( $stats['posts'] ) ) . ''
+ );
+ ?>
+
+
+
+
+ ' . esc_html( number_format_i18n( $stats['followers'] ) ) . ''
+ );
+ ?>
+
+
+
+
+
+
+
+ $modal_content,
+ /* translators: %s: Profile name. */
+ 'title' => sprintf( esc_html__( 'Follow %s', 'activitypub' ), esc_html( $actor->get_name() ) ),
+ )
+ );
+ ?>
+
diff --git a/build/blocks/follow-me/style-index-rtl.css b/build/blocks/follow-me/style-index-rtl.css
new file mode 100644
index 000000000..5c9ec8c63
--- /dev/null
+++ b/build/blocks/follow-me/style-index-rtl.css
@@ -0,0 +1 @@
+body.modal-open{overflow:hidden}.activitypub-modal__overlay{align-items:center;background-color:rgba(0,0,0,.5);bottom:0;color:initial;display:flex;justify-content:center;right:0;padding:1rem;position:fixed;left:0;top:0;z-index:100000}.activitypub-modal__overlay.compact{align-items:flex-start;background-color:transparent;bottom:auto;justify-content:flex-start;right:auto;padding:0;position:absolute;left:auto;top:auto;z-index:100}.activitypub-modal__overlay[hidden]{display:none}.activitypub-modal__frame{animation:activitypub-modal-appear .2s ease-out;background-color:var(--wp--preset--color--white,#fff);border-radius:8px;box-shadow:0 5px 15px rgba(0,0,0,.3);display:flex;flex-direction:column;max-height:calc(100vh - 2rem);max-width:660px;overflow:hidden;width:100%}.compact .activitypub-modal__frame{box-shadow:0 2px 8px rgba(0,0,0,.1);max-height:300px;max-width:-moz-min-content;max-width:min-content;min-width:250px;width:auto}.activitypub-modal__header{align-items:center;border-bottom:1px solid var(--wp--preset--color--light-gray,#f0f0f0);display:flex;flex-shrink:0;justify-content:space-between;padding:2rem 2rem 1.5rem}.compact .activitypub-modal__header{display:none}.activitypub-modal__header .activitypub-modal__close{align-items:center;border:none;cursor:pointer;display:flex;justify-content:center;padding:.5rem;width:auto}.activitypub-modal__header .activitypub-modal__close:active{border:none;padding:.5rem}.activitypub-modal__title{font-size:130%;font-weight:600;line-height:1.4;margin:0!important}.activitypub-modal__content{overflow-y:auto}@keyframes activitypub-modal-appear{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}.activitypub-follow-me-block-wrapper{display:block;margin:1rem 0;position:relative}.activitypub-follow-me-block-wrapper .activitypub-profile{padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile__body{display:flex;flex-wrap:wrap}.activitypub-follow-me-block-wrapper .activitypub-profile__avatar{border-radius:50%;height:75px;margin-left:1rem;-o-object-fit:cover;object-fit:cover;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile__content{align-items:center;display:flex;flex:1;flex-wrap:wrap;justify-content:space-between;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile__info{display:block;flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile__name{font-size:1.25em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile__name{color:inherit;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile div.wp-block-button{align-items:center;display:flex;margin:0 1rem 0 0}.activitypub-follow-me-block-wrapper .activitypub-profile .wp-block-button__link{margin:0}.activitypub-follow-me-block-wrapper .activitypub-profile .is-small{font-size:.8rem;padding:.25rem .5rem}.activitypub-follow-me-block-wrapper .activitypub-profile .is-compact{font-size:.9rem;padding:.4rem .8rem}.activitypub-follow-me-block-wrapper:not(.is-style-button-only):not(.is-style-profile) .activitypub-profile__bio,.activitypub-follow-me-block-wrapper:not(.is-style-button-only):not(.is-style-profile) .activitypub-profile__stats{display:none}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile{padding:0}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__body{display:block;padding:0}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__content{display:inline}.activitypub-follow-me-block-wrapper.is-style-button-only div.wp-block-button{display:inline-block;margin:0}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__avatar,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__bio,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__handle,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__name,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__stats{display:none}.activitypub-follow-me-block-wrapper.is-style-profile{border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1);overflow:hidden}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile,.activitypub-follow-me-block-wrapper.is-style-profile.has-background .activitypub-profile{padding:0}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__header{background-color:#ccc;background-position:50%;background-size:cover;height:120px;width:100%}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__body{padding:1rem}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__avatar{height:64px;width:64px}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__name{margin-bottom:.25rem}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__bio{font-size:90%;line-height:1.4;margin-top:16px;width:100%}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__bio p{margin:0 0 .5rem}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__bio p:last-child{margin-bottom:0}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__stats{display:flex;font-size:.9em;gap:16px;margin-top:1rem;width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border .activitypub-profile{padding-right:1rem;padding-left:1rem}.activitypub-dialog__section{border-bottom:1px solid var(--wp--preset--color--light-gray,#f0f0f0);padding:1.5rem 2rem}.activitypub-dialog__section:last-child{border-bottom:none;padding-bottom:2rem}.activitypub-dialog__section h4{font-size:110%;margin-bottom:.5rem;margin-top:0}.activitypub-dialog__description{color:inherit;font-size:95%;margin-bottom:1rem}.activitypub-dialog__button-group{display:flex;margin-bottom:.5rem;width:100%}.activitypub-dialog__button-group input[type]{border:1px solid var(--wp--preset--color--gray,#e2e4e7);border-radius:0 4px 4px 0;flex:1;line-height:1;margin:0}.activitypub-dialog__button-group input[type]::-moz-placeholder{opacity:.5}.activitypub-dialog__button-group input[type]::placeholder{opacity:.5}.activitypub-dialog__button-group input[type][aria-invalid=true]{border-color:var(--wp--preset--color--vivid-red)}.activitypub-dialog__button-group button{border-radius:4px 0 0 4px!important;margin-right:-1px!important;min-width:22.5%;width:auto}.activitypub-dialog__error{color:var(--wp--preset--color--vivid-red);font-size:90%;margin-top:.5rem}
diff --git a/build/blocks/follow-me/style-index.css b/build/blocks/follow-me/style-index.css
new file mode 100644
index 000000000..386060cdf
--- /dev/null
+++ b/build/blocks/follow-me/style-index.css
@@ -0,0 +1 @@
+body.modal-open{overflow:hidden}.activitypub-modal__overlay{align-items:center;background-color:rgba(0,0,0,.5);bottom:0;color:initial;display:flex;justify-content:center;left:0;padding:1rem;position:fixed;right:0;top:0;z-index:100000}.activitypub-modal__overlay.compact{align-items:flex-start;background-color:transparent;bottom:auto;justify-content:flex-start;left:auto;padding:0;position:absolute;right:auto;top:auto;z-index:100}.activitypub-modal__overlay[hidden]{display:none}.activitypub-modal__frame{animation:activitypub-modal-appear .2s ease-out;background-color:var(--wp--preset--color--white,#fff);border-radius:8px;box-shadow:0 5px 15px rgba(0,0,0,.3);display:flex;flex-direction:column;max-height:calc(100vh - 2rem);max-width:660px;overflow:hidden;width:100%}.compact .activitypub-modal__frame{box-shadow:0 2px 8px rgba(0,0,0,.1);max-height:300px;max-width:-moz-min-content;max-width:min-content;min-width:250px;width:auto}.activitypub-modal__header{align-items:center;border-bottom:1px solid var(--wp--preset--color--light-gray,#f0f0f0);display:flex;flex-shrink:0;justify-content:space-between;padding:2rem 2rem 1.5rem}.compact .activitypub-modal__header{display:none}.activitypub-modal__header .activitypub-modal__close{align-items:center;border:none;cursor:pointer;display:flex;justify-content:center;padding:.5rem;width:auto}.activitypub-modal__header .activitypub-modal__close:active{border:none;padding:.5rem}.activitypub-modal__title{font-size:130%;font-weight:600;line-height:1.4;margin:0!important}.activitypub-modal__content{overflow-y:auto}@keyframes activitypub-modal-appear{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}.activitypub-follow-me-block-wrapper{display:block;margin:1rem 0;position:relative}.activitypub-follow-me-block-wrapper .activitypub-profile{padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile__body{display:flex;flex-wrap:wrap}.activitypub-follow-me-block-wrapper .activitypub-profile__avatar{border-radius:50%;height:75px;margin-right:1rem;-o-object-fit:cover;object-fit:cover;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile__content{align-items:center;display:flex;flex:1;flex-wrap:wrap;justify-content:space-between;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile__info{display:block;flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile__name{font-size:1.25em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile__name{color:inherit;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile div.wp-block-button{align-items:center;display:flex;margin:0 0 0 1rem}.activitypub-follow-me-block-wrapper .activitypub-profile .wp-block-button__link{margin:0}.activitypub-follow-me-block-wrapper .activitypub-profile .is-small{font-size:.8rem;padding:.25rem .5rem}.activitypub-follow-me-block-wrapper .activitypub-profile .is-compact{font-size:.9rem;padding:.4rem .8rem}.activitypub-follow-me-block-wrapper:not(.is-style-button-only):not(.is-style-profile) .activitypub-profile__bio,.activitypub-follow-me-block-wrapper:not(.is-style-button-only):not(.is-style-profile) .activitypub-profile__stats{display:none}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile{padding:0}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__body{display:block;padding:0}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__content{display:inline}.activitypub-follow-me-block-wrapper.is-style-button-only div.wp-block-button{display:inline-block;margin:0}.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__avatar,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__bio,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__handle,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__name,.activitypub-follow-me-block-wrapper.is-style-button-only .activitypub-profile__stats{display:none}.activitypub-follow-me-block-wrapper.is-style-profile{border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1);overflow:hidden}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile,.activitypub-follow-me-block-wrapper.is-style-profile.has-background .activitypub-profile{padding:0}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__header{background-color:#ccc;background-position:50%;background-size:cover;height:120px;width:100%}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__body{padding:1rem}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__avatar{height:64px;width:64px}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__name{margin-bottom:.25rem}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__bio{font-size:90%;line-height:1.4;margin-top:16px;width:100%}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__bio p{margin:0 0 .5rem}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__bio p:last-child{margin-bottom:0}.activitypub-follow-me-block-wrapper.is-style-profile .activitypub-profile__stats{display:flex;font-size:.9em;gap:16px;margin-top:1rem;width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border .activitypub-profile{padding-left:1rem;padding-right:1rem}.activitypub-dialog__section{border-bottom:1px solid var(--wp--preset--color--light-gray,#f0f0f0);padding:1.5rem 2rem}.activitypub-dialog__section:last-child{border-bottom:none;padding-bottom:2rem}.activitypub-dialog__section h4{font-size:110%;margin-bottom:.5rem;margin-top:0}.activitypub-dialog__description{color:inherit;font-size:95%;margin-bottom:1rem}.activitypub-dialog__button-group{display:flex;margin-bottom:.5rem;width:100%}.activitypub-dialog__button-group input[type]{border:1px solid var(--wp--preset--color--gray,#e2e4e7);border-radius:4px 0 0 4px;flex:1;line-height:1;margin:0}.activitypub-dialog__button-group input[type]::-moz-placeholder{opacity:.5}.activitypub-dialog__button-group input[type]::placeholder{opacity:.5}.activitypub-dialog__button-group input[type][aria-invalid=true]{border-color:var(--wp--preset--color--vivid-red)}.activitypub-dialog__button-group button{border-radius:0 4px 4px 0!important;margin-left:-1px!important;min-width:22.5%;width:auto}.activitypub-dialog__error{color:var(--wp--preset--color--vivid-red);font-size:90%;margin-top:.5rem}
diff --git a/build/follow-me/style-view-rtl.css b/build/blocks/follow-me/style-view-rtl.css
similarity index 100%
rename from build/follow-me/style-view-rtl.css
rename to build/blocks/follow-me/style-view-rtl.css
diff --git a/build/follow-me/style-view.css b/build/blocks/follow-me/style-view.css
similarity index 100%
rename from build/follow-me/style-view.css
rename to build/blocks/follow-me/style-view.css
diff --git a/build/blocks/follow-me/view.asset.php b/build/blocks/follow-me/view.asset.php
new file mode 100644
index 000000000..7fd88c86f
--- /dev/null
+++ b/build/blocks/follow-me/view.asset.php
@@ -0,0 +1 @@
+ array('@wordpress/interactivity'), 'version' => '1bdc53d1581dc837c6e5', 'type' => 'module');
diff --git a/build/blocks/follow-me/view.js b/build/blocks/follow-me/view.js
new file mode 100644
index 000000000..0af3dcf1b
--- /dev/null
+++ b/build/blocks/follow-me/view.js
@@ -0,0 +1 @@
+import*as t from"@wordpress/interactivity";var e={d:(t,o)=>{for(var n in o)e.o(o,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:o[n]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const o=(r={getContext:()=>t.getContext,getElement:()=>t.getElement,store:()=>t.store},l={},e.d(l,r),l),n={computedStyles:null,variables:{}};var r,l;function c(t){if("undefined"==typeof window||!window.getComputedStyle)return!1;if(n.variables.hasOwnProperty(t))return n.variables[t];n.computedStyles||(n.computedStyles=window.getComputedStyle(document.documentElement));const e=n.computedStyles.getPropertyValue(t).trim();return n.variables[t]=""!==e,n.variables[t]}function i(t){if("string"!=typeof t)return null;if(t.match(/^#/))return t.substring(0,7);const[,,e]=t.split("|"),o=`--wp--preset--color--${e}`;return c(o)?`var(${o})`:null}function a(t,e,o=null,n=""){return o?`${t}${n} { ${e}: ${o}; }\n`:""}function s(t,e,o,n){return a(t,"background-color",e)+a(t,"color",o)+a(t,"background-color",n,":hover")+a(t,"background-color",n,":focus")}const{apiFetch:d}=window.wp;!function(){const{actions:t,callbacks:e}=(0,o.store)("activitypub/follow-me",{actions:{openModal(t){const n=(0,o.getContext)();n.modal.isOpen=!0,n.modal.isCompact?setTimeout(e.positionModal,0):setTimeout((()=>{const t=document.getElementById(n.blockId);if(t){const o=t.querySelector(".activitypub-modal__frame");o&&e.trapFocus(o)}}),50),"function"==typeof e.onModalOpen&&e.onModalOpen(t)},closeModal(t){const n=(0,o.getContext)();n.modal.isOpen=!1;const r=(0,o.getElement)();if("actions.toggleModal"===r.ref.dataset["wpOn-Click"])r.ref.focus();else{const t=document.getElementById(n.blockId);if(t){const e=t.querySelector('[data-wp-on--click="actions.toggleModal"], [data-wp-on-async--click="actions.toggleModal"]');e&&e.focus()}}"function"==typeof e.onModalClose&&e.onModalClose(t)},toggleModal(e){const{modal:n}=(0,o.getContext)();n.isOpen?t.closeModal(e):t.openModal(e)}},callbacks:{_abortController:null,handleModalEffects(){const{modal:t}=(0,o.getContext)();if(t.isOpen&&!t.isCompact?document.body.classList.add("modal-open"):document.body.classList.remove("modal-open"),e._abortController&&(e._abortController.abort(),e._abortController=null),t.isOpen){e._abortController=new AbortController;const{signal:t}=e._abortController;document.addEventListener("keydown",e.documentKeydown,{signal:t}),document.addEventListener("click",e.documentClick,{signal:t})}},documentKeydown(e){const{modal:n}=(0,o.getContext)();n.isOpen&&"Escape"===e.key&&t.closeModal()},documentClick(e){const{blockId:n,modal:r}=(0,o.getContext)();if(!r.isOpen)return;const l=document.getElementById(n);if(!l)return;const c=l.querySelector('.wp-element-button[data-wp-on--click="actions.toggleModal"]');if(c&&(c===e.target||c.contains(e.target)))return;const i=l.querySelector(".activitypub-modal__frame");i&&!i.contains(e.target)&&t.closeModal()},positionModal(){const{blockId:t}=(0,o.getContext)(),e=document.getElementById(t);if(!e)return;const n=e.querySelector(".activitypub-modal__overlay");if(!n)return;n.style.top="",n.style.left="",n.style.right="",n.style.bottom="";const r=(0,o.getElement)().ref.getBoundingClientRect(),l=window.innerWidth,c=e.getBoundingClientRect();let i={top:r.bottom-c.top+8+"px",left:r.left-c.left-2+"px"};l-r.right<250&&(i.left="auto",i.right=c.right-r.right+"px"),Object.assign(n.style,i)},trapFocus(t){const e=t.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]):not([readonly]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])'),o=e[0],n=e[e.length-1];o&&o.classList.contains("activitypub-modal__close")&&e.length>1?e[1].focus():o.focus(),t.addEventListener("keydown",(function(t){"Tab"!==t.key&&9!==t.keyCode||(t.shiftKey?document.activeElement===o&&(n.focus(),t.preventDefault()):document.activeElement===n&&(o.focus(),t.preventDefault()))}))}}})}();const{actions:u,callbacks:p,state:m}=(0,o.store)("activitypub/follow-me",{actions:{copyToClipboard(){const t=(0,o.getContext)();navigator.clipboard.writeText(t.webfinger).then((()=>{t.copyButtonText=m.i18n.copied,setTimeout((()=>{t.copyButtonText=m.i18n.copy}),1e3)}),(t=>{console.error("Could not copy text: ",t)}))},updateRemoteProfile(t){const e=(0,o.getContext)();e.remoteProfile=t.target.value,e.isError=!1,e.errorMessage=""},onKeydown(t){"A"!==(0,o.getElement)().ref.tagName||"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),u.toggleModal(t))},handleKeyDown(t){"Enter"===t.key&&(t.preventDefault(),u.submitRemoteProfile())},submitRemoteProfile:function*(){const t=(0,o.getContext)(),{namespace:e}=m,n=t.remoteProfile.trim();if(!n)return t.isError=!0,void(t.errorMessage=m.i18n.emptyProfileError);if(!p.isHandle(n))return t.isError=!0,void(t.errorMessage=m.i18n.invalidProfileError);t.isLoading=!0,t.isError=!1;const r=`/${e}/actors/${t.userId}/remote-follow?resource=${encodeURIComponent(n)}`;try{const e=yield d({path:r});t.isLoading=!1,window.open(e.url,"_blank"),u.closeModal(new Event("click"))}catch(e){console.error("Error submitting profile:",e),t.isLoading=!1,t.isError=!0,t.errorMessage=e.message||m.i18n.genericError}}},callbacks:{initButtonStyles:()=>{const{buttonStyle:t,backgroundColor:e,blockId:n}=(0,o.getContext)();if(n&&t){const o=document.createElement("style"),l=`#${n}`;o.textContent=function(t,e,o){const n=`${t} .wp-block-button__link`,r=function(t){if("string"==typeof t){const e=`--wp--preset--color--${t}`;return c(e)?`var(${e})`:null}return t?.color?.background||null}(o)||e?.color?.background;return s(n,i(e?.elements?.link?.color?.text),r,i(e?.elements?.link?.[":hover"]?.color?.text))}(l,t,e),document.head.appendChild(o);const a=document.createElement("style");a.textContent=(r=t,s(".activitypub-dialog__button-group .wp-block-button",i(r?.elements?.link?.color?.text)||"#111","#fff",i(r?.elements?.link?.[":hover"]?.color?.text)||"#333")),document.head.appendChild(a)}var r},isHandle(t){const e=t.replace(/^@/,"").split("@");return 2===e.length&&p.isUrl(`https://${e[1]}`)},isUrl(t){try{return new URL(t),!0}catch(t){return!1}},onModalClose(){(0,o.getContext)().isError=!1}}});
\ No newline at end of file
diff --git a/build/followers/block.json b/build/blocks/followers/block.json
similarity index 71%
rename from build/followers/block.json
rename to build/blocks/followers/block.json
index 078b82f30..929cf5855 100644
--- a/build/followers/block.json
+++ b/build/blocks/followers/block.json
@@ -2,23 +2,20 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/followers",
"apiVersion": 3,
- "version": "1.0.0",
+ "version": "2.0.1",
"title": "Fediverse Followers",
"category": "widgets",
"description": "Display your followers from the Fediverse on your website.",
"textdomain": "activitypub",
"icon": "groups",
"supports": {
- "html": false
+ "html": false,
+ "interactivity": true
},
"attributes": {
- "title": {
- "type": "string",
- "default": "Fediverse Followers"
- },
"selectedUser": {
"type": "string",
- "default": "site"
+ "default": "blog"
},
"per_page": {
"type": "number",
@@ -40,12 +37,12 @@
"styles": [
{
"name": "default",
- "label": "No Lines",
+ "label": "Default",
"isDefault": true
},
{
- "name": "with-lines",
- "label": "Lines"
+ "name": "card",
+ "label": "Card"
},
{
"name": "compact",
@@ -53,9 +50,11 @@
}
],
"editorScript": "file:./index.js",
- "viewScript": "file:./view.js",
+ "editorStyle": "file:./index.css",
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
"style": [
- "file:./style-view.css",
- "wp-block-query-pagination"
- ]
+ "file:./style-index.css"
+ ],
+ "render": "file:./render.php"
}
\ No newline at end of file
diff --git a/build/followers/index.asset.php b/build/blocks/followers/index.asset.php
similarity index 81%
rename from build/followers/index.asset.php
rename to build/blocks/followers/index.asset.php
index 02a90ee07..1c4d95a5e 100644
--- a/build/followers/index.asset.php
+++ b/build/blocks/followers/index.asset.php
@@ -1 +1 @@
- array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '0090f7363e3c09edb5b6');
+ array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '41a177c9fbbe060ac05f');
diff --git a/build/blocks/followers/index.js b/build/blocks/followers/index.js
new file mode 100644
index 000000000..1b644db5f
--- /dev/null
+++ b/build/blocks/followers/index.js
@@ -0,0 +1,2 @@
+(()=>{"use strict";var e,t={20:(e,t,r)=>{var a=r(609),l=Symbol.for("react.element"),o=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),n=a.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,s={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var a,i={},c=null,p=null;for(a in void 0!==r&&(c=""+r),void 0!==t.key&&(c=""+t.key),void 0!==t.ref&&(p=t.ref),t)o.call(t,a)&&!s.hasOwnProperty(a)&&(i[a]=t[a]);if(e&&e.defaultProps)for(a in t=e.defaultProps)void 0===i[a]&&(i[a]=t[a]);return{$$typeof:l,type:e,key:c,ref:p,props:i,_owner:n.current}}},309:(e,t,r)=>{const a=window.wp.blocks,l=window.wp.primitives;var o=r(848);const n=(0,o.jsx)(l.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,o.jsx)(l.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),s=[{attributes:{title:{type:"string",default:"Fediverse Followers"},selectedUser:{type:"string",default:"blog"},per_page:{type:"number",default:10},order:{type:"string",default:"desc",enum:["asc","desc"]}},supports:{html:!1},isEligible:({title:e})=>!!e,migrate:({title:e,...t})=>[t,[(0,a.createBlock)("core/heading",{content:e,level:3})]]}];var i=r(609);const c=window.wp.apiFetch;var p=r.n(c);const u=window.wp.components,d=window.wp.blockEditor,v=window.wp.coreData,m=window.wp.data,f=window.wp.element,w=window.wp.url,g=window.wp.i18n;function h(){return window._activityPubOptions||{}}function b({name:e}){const{enabled:t}=h(),r=t?.blog?"":(0,g.__)("It will be empty in other non-author contexts.","activitypub"),a=(0,g.sprintf)(/* translators: %1$s: block name, %2$s: extra information for non-author context */ /* translators: %1$s: block name, %2$s: extra information for non-author context */
+(0,g.__)("This %1$s block will adapt to the page it is on, displaying the user profile associated with a post author (in a loop) or a user archive. %2$s","activitypub"),e,r).trim();return(0,i.createElement)(u.Card,null,(0,i.createElement)(u.CardBody,null,(0,f.createInterpolateElement)(a,{strong:(0,i.createElement)("strong",null)})))}function _({selectedUser:e,per_page:t,order:r,page:a,setPage:l,followerData:o=!1}){const n="blog"===e?0:e,[s,c]=(0,f.useState)([]),[u,d]=(0,f.useState)(0),[v,m]=(0,f.useState)(0),[b,_]=(0,f.useState)(1),x=a||b,k=l||_,N=(e,r)=>{c(e),m(r),d(Math.ceil(r/t))};return(0,f.useEffect)((()=>{if(o&&1===x)return N(o.followers,o.total);const e=function(e,t,r,a){const{namespace:l}=h(),o=`/${l}/actors/${e}/followers`,n={per_page:t,order:r,page:a,context:"full"};return(0,w.addQueryArgs)(o,n)}(n,t,r,x);p()({path:e}).then((({orderedItems:e,totalItems:t})=>N(e,t))).catch((()=>N([],0)))}),[n,t,r,x,o]),(0,i.createElement)("div",{className:"followers-container"},s.length?(0,i.createElement)("ul",{className:"followers-list"},s.map((e=>(0,i.createElement)("li",{key:e.url,className:"follower-item"},(0,i.createElement)(E,{...e}))))):(0,i.createElement)("p",{className:"followers-placeholder"},(0,g.__)("No followers found.","activitypub")),(0,i.createElement)(y,{page:x,pages:u,setPage:k}))}function y({page:e,pages:t,setPage:r}){if(t<=1)return null;const a=e<=1,l=e>=t;return(0,i.createElement)("nav",{className:"followers-pagination",role:"navigation"},(0,i.createElement)("h1",{className:"screen-reader-text"},(0,g.__)("Follower navigation","activitypub")),(0,i.createElement)("a",{className:"pagination-previous","aria-disabled":a,"aria-label":(0,g.__)("Previous page","activitypub"),onClick:t=>{t.preventDefault(),r(e-1)}},(0,g.__)("Previous","activitypub")),(0,i.createElement)("div",{className:"pagination-info"},`${e} / ${t}`),(0,i.createElement)("a",{className:"pagination-next","aria-disabled":l,"aria-label":(0,g.__)("Next page","activitypub"),onClick:t=>{t.preventDefault(),r(e+1)}},(0,g.__)("Next","activitypub")))}function E({name:e,icon:t,url:r,preferredUsername:a}){const l=`@${a}`,{defaultAvatarUrl:o}=h(),n=t.url||o;return(0,i.createElement)("a",{className:"follower-link",href:r,title:l,onClick:e=>e.preventDefault()},(0,i.createElement)("img",{width:"48",height:"48",src:n,className:"follower-avatar",alt:e,onError:e=>{e.target.src=o}}),(0,i.createElement)("div",{className:"follower-info"},(0,i.createElement)("span",{className:"follower-name"},e),(0,i.createElement)("span",{className:"follower-username"},l)),(0,i.createElement)("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",width:"24",height:"24",className:"external-link-icon","aria-hidden":"true",focusable:"false",fill:"currentColor"},(0,i.createElement)("path",{d:"M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z"})))}const x=JSON.parse('{"$schema":"https://schemas.wp.org/trunk/block.json","name":"activitypub/followers","apiVersion":3,"version":"2.0.1","title":"Fediverse Followers","category":"widgets","description":"Display your followers from the Fediverse on your website.","textdomain":"activitypub","icon":"groups","supports":{"html":false,"interactivity":true},"attributes":{"selectedUser":{"type":"string","default":"blog"},"per_page":{"type":"number","default":10},"order":{"type":"string","default":"desc","enum":["asc","desc"]}},"usesContext":["postType","postId"],"styles":[{"name":"default","label":"Default","isDefault":true},{"name":"card","label":"Card"},{"name":"compact","label":"Compact"}],"editorScript":"file:./index.js","editorStyle":"file:./index.css","viewScriptModule":"file:./view.js","viewScript":"wp-api-fetch","style":["file:./style-index.css"],"render":"file:./render.php"}');(0,a.registerBlockType)(x,{deprecated:s,edit:function({attributes:e,setAttributes:t,context:{postType:r,postId:a}}){const{className:l="",order:o,per_page:n,selectedUser:s}=e,c=(0,d.useBlockProps)(),[p,w]=(0,f.useState)(1),y=[{label:(0,g.__)("New to old","activitypub"),value:"desc"},{label:(0,g.__)("Old to new","activitypub"),value:"asc"}],E=function({withInherit:e=!1}){const{enabled:t}=h(),r=t?.users?(0,m.useSelect)((e=>e("core").getUsers({capabilities:"activitypub"})),[]):[];return(0,f.useMemo)((()=>{if(!r)return[];const a=[];return t?.blog&&a.push({label:(0,g.__)("Blog","activitypub"),value:"blog"}),e&&t?.users&&a.push({label:(0,g.__)("Dynamic User","activitypub"),value:"inherit"}),r.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),a)}),[r])}({withInherit:!0}),x=e=>r=>{w(1),t({[e]:r})},k=(0,m.useSelect)((e=>{const{getEditedEntityRecord:t}=e(v.store),l=t("postType",r,a)?.author;return null!=l?l:null}),[r,a]);(0,f.useEffect)((()=>{E.length&&(E.find((({value:e})=>e===s))||t({selectedUser:E[0].value}))}),[s,E]);const N=[["core/heading",{level:3,placeholder:(0,g.__)("Fediverse Followers","activitypub"),content:(0,g.__)("Fediverse Followers","activitypub")}]];return(0,i.createElement)("div",{...c},(0,i.createElement)(d.InspectorControls,{key:"setting"},(0,i.createElement)(u.PanelBody,{title:(0,g.__)("Followers Options","activitypub")},E.length>1&&(0,i.createElement)(u.SelectControl,{label:(0,g.__)("Select User","activitypub"),value:s,options:E,onChange:x("selectedUser"),__next40pxDefaultSize:!0,__nextHasNoMarginBottom:!0}),(0,i.createElement)(u.SelectControl,{label:(0,g.__)("Sort","activitypub"),value:o,options:y,onChange:x("order"),__next40pxDefaultSize:!0,__nextHasNoMarginBottom:!0}),(0,i.createElement)(u.RangeControl,{label:(0,g.__)("Number of Followers","activitypub"),value:n,onChange:x("per_page"),min:1,max:10,__next40pxDefaultSize:!0,__nextHasNoMarginBottom:!0}))),(0,i.createElement)("div",{className:"wp-block-activitypub-followers "+l},(0,i.createElement)(d.InnerBlocks,{template:N,allowedBlocks:["core/heading"],templateLock:"all",renderAppender:!1}),"inherit"===s?k?(0,i.createElement)(_,{...e,page:p,setPage:w,selectedUser:k}):(0,i.createElement)(b,{name:(0,g.__)("Followers","activitypub")}):(0,i.createElement)(_,{...e,page:p,setPage:w})))},save:function(){const e=d.useBlockProps.save(),t=d.useInnerBlocksProps.save(e);return(0,i.createElement)("div",{...t})},icon:n})},609:e=>{e.exports=window.React},848:(e,t,r)=>{e.exports=r(20)}},r={};function a(e){var l=r[e];if(void 0!==l)return l.exports;var o=r[e]={exports:{}};return t[e](o,o.exports,a),o.exports}a.m=t,e=[],a.O=(t,r,l,o)=>{if(!r){var n=1/0;for(p=0;p=o)&&Object.keys(a.O).every((e=>a.O[e](r[i])))?r.splice(i--,1):(s=!1,o0&&e[p-1][2]>o;p--)e[p]=e[p-1];e[p]=[r,l,o]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={488:0,424:0};a.O.j=t=>0===e[t];var t=(t,r)=>{var l,o,[n,s,i]=r,c=0;if(n.some((t=>0!==e[t]))){for(l in s)a.o(s,l)&&(a.m[l]=s[l]);if(i)var p=i(a)}for(t&&t(r);ca(309)));l=a.O(l)})();
\ No newline at end of file
diff --git a/build/blocks/followers/render.php b/build/blocks/followers/render.php
new file mode 100644
index 000000000..f9493c048
--- /dev/null
+++ b/build/blocks/followers/render.php
@@ -0,0 +1,168 @@
+' . esc_html( $_title ) . '';
+ unset( $attributes['title'], $attributes['className'] );
+} else {
+ $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
+}
+
+$user_id = Blocks::get_user_id( $attributes['selectedUser'] );
+if ( is_null( $user_id ) ) {
+ return '';
+}
+
+$user = Actors::get_by_id( $user_id );
+if ( is_wp_error( $user ) ) {
+ return '';
+}
+
+$_per_page = absint( $attributes['per_page'] );
+$follower_data = Followers::get_followers_with_count( $user_id, $_per_page );
+
+// Prepare Followers data for the Interactivity API context.
+$followers = array_map(
+ /**
+ * Prepare follower data for the Interactivity API context.
+ *
+ * @param WP_Post $follower Follower object.
+ *
+ * @return array
+ */
+ function ( $follower ) {
+ $actor = Actors::get_actor( $follower );
+ $username = $actor->get_preferred_username();
+
+ return array(
+ 'handle' => '@' . $username,
+ 'icon' => $actor->get_icon(),
+ 'name' => $actor->get_name() ?? $username,
+ 'url' => object_to_uri( $actor->get_url() ) ?? $actor->get_id(),
+ );
+ },
+ $follower_data['followers']
+);
+
+// Set up the Interactivity API state.
+wp_interactivity_state(
+ 'activitypub/followers',
+ array(
+ 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
+ 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
+ )
+);
+
+// Set initial context data.
+$context = array(
+ 'followers' => $followers,
+ 'isLoading' => false,
+ 'order' => $attributes['order'],
+ 'page' => 1,
+ 'pages' => ceil( $follower_data['total'] / $_per_page ),
+ 'per_page' => $_per_page,
+ 'total' => $follower_data['total'],
+ 'userId' => $user_id,
+);
+
+// Get block wrapper attributes with the data-wp-interactive attribute.
+$wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'id' => wp_unique_id( 'activitypub-followers-block-' ),
+ 'data-wp-interactive' => 'activitypub/followers',
+ 'data-wp-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
+ )
+);
+?>
+
+>
+
+
+
+
+
+ $_per_page ) : ?>
+
+
+
+
+
+
diff --git a/build/blocks/followers/style-index-rtl.css b/build/blocks/followers/style-index-rtl.css
new file mode 100644
index 000000000..3173399f4
--- /dev/null
+++ b/build/blocks/followers/style-index-rtl.css
@@ -0,0 +1 @@
+button{border:none}.wp-block-activitypub-followers{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;margin:16px 0}.wp-block-activitypub-followers .wp-block-heading{border-bottom:1px solid;margin:0 0 16px;padding:0 0 8px}.wp-block-activitypub-followers .followers-pagination,.wp-block-activitypub-followers .wp-block-heading{border-color:var(--wp--preset--color--foreground,var(--wp--preset--color--primary,#e0e0e0))}.wp-block-activitypub-followers .followers-container{position:relative}.wp-block-activitypub-followers .followers-container .followers-list{list-style:none;margin:0;padding:0}.wp-block-activitypub-followers .followers-container .follower-item{margin:0 0 8px}.wp-block-activitypub-followers .followers-container .follower-item:last-child{margin-bottom:0}.wp-block-activitypub-followers .followers-container .follower-link{align-items:center;border:none;border-radius:8px;box-shadow:none;display:flex;padding:8px;transition:background-color .2s ease}.wp-block-activitypub-followers .followers-container .follower-link:focus,.wp-block-activitypub-followers .followers-container .follower-link:hover{background-color:var(--wp--preset--color--subtle-background,var(--wp--preset--color--accent-2,var(--wp--preset--color--tertiary,var(--wp--preset--color--secondary,#f0f0f0))));box-shadow:none;outline:none}.wp-block-activitypub-followers .followers-container .follower-link:focus .external-link-icon,.wp-block-activitypub-followers .followers-container .follower-link:hover .external-link-icon{opacity:1}.wp-block-activitypub-followers .followers-container .follower-avatar{border:1px solid #e0e0e0;border-radius:50%;height:48px;margin-left:16px;-o-object-fit:cover;object-fit:cover;width:48px}.wp-block-activitypub-followers .followers-container .follower-info{display:flex;flex:1;flex-direction:column;line-height:1.3;overflow:hidden}.wp-block-activitypub-followers .followers-container .follower-name{font-weight:600;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wp-block-activitypub-followers .followers-container .follower-username{color:var(--wp--preset--color--very-dark-gray,#666);font-size:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wp-block-activitypub-followers .followers-container .external-link-icon{height:16px;margin-right:8px;transition:opacity .2s ease;width:16px}.wp-block-activitypub-followers .followers-container .followers-pagination{align-items:center;border-top-style:solid;border-top-width:1px;display:grid;grid-template-columns:1fr auto 1fr;margin-top:16px;padding-top:8px!important}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-info{color:var(--wp--preset--color--very-dark-gray,#666);font-size:90%;justify-self:center}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next,.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous{border:none;box-shadow:none;cursor:pointer;display:inline-block;font-size:90%;min-width:60px;padding:8px 0}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next[hidden],.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous[hidden]{display:none!important}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next[aria-disabled=true],.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous[aria-disabled=true]{cursor:not-allowed;opacity:.3;pointer-events:none;text-decoration:none}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous{justify-self:start;padding-left:8px}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous:before{content:"←"}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next{justify-self:end;padding-right:8px;text-align:left}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next:after{content:"→"}@media(max-width:480px){.wp-block-activitypub-followers .followers-container .followers-pagination{grid-template-columns:1fr 1fr}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-info{display:none}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next,.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous{align-items:center;font-size:100%;min-height:44px}}.wp-block-activitypub-followers .followers-container .followers-loading{align-items:center;background-color:hsla(0,0%,100%,.5);border-radius:8px;bottom:0;display:flex;justify-content:center;right:0;position:absolute;left:0;top:0}.wp-block-activitypub-followers .followers-container .followers-loading[aria-hidden=true]{display:none}.wp-block-activitypub-followers .followers-container .loading-spinner{animation:spin 1s ease-in-out infinite;border:3px solid color-mix(in srgb,var(--wp--preset--color--primary,#0073aa) 30%,transparent);border-radius:50%;border-top-color:var(--wp--preset--color--primary,#0073aa);height:40px;width:40px}@keyframes spin{to{transform:rotate(-1turn)}}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block){background-color:var(--wp--preset--color--white,#fff);border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1);box-sizing:border-box;padding:24px}@media(max-width:480px){.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block){margin-right:-12px;margin-left:-12px}}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .wp-block-heading{border-bottom:none;margin-bottom:16px;text-align:center}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .follower-link{border:1px solid #e0e0e0;margin-bottom:8px}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .follower-link:focus,.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .follower-link:hover{border-color:#c7c7c7}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .followers-pagination{border:none;padding-bottom:0!important}.wp-block-activitypub-followers.is-style-compact .follower-link{padding:4px}.wp-block-activitypub-followers.is-style-compact .follower-avatar{height:36px;margin-left:8px;width:36px}.wp-block-activitypub-followers.is-style-compact .follower-name{font-size:90%}.wp-block-activitypub-followers.is-style-compact .follower-username{font-size:80%}.wp-block-activitypub-followers.is-style-compact .followers-pagination{margin-top:8px;padding-top:4px}.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-next,.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-previous{font-size:80%;padding-bottom:4px;padding-top:4px}@media(max-width:480px){.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-next,.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-previous{font-size:100%}}.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-info{font-size:80%}
diff --git a/build/blocks/followers/style-index.css b/build/blocks/followers/style-index.css
new file mode 100644
index 000000000..f1afc8a64
--- /dev/null
+++ b/build/blocks/followers/style-index.css
@@ -0,0 +1 @@
+button{border:none}.wp-block-activitypub-followers{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;margin:16px 0}.wp-block-activitypub-followers .wp-block-heading{border-bottom:1px solid;margin:0 0 16px;padding:0 0 8px}.wp-block-activitypub-followers .followers-pagination,.wp-block-activitypub-followers .wp-block-heading{border-color:var(--wp--preset--color--foreground,var(--wp--preset--color--primary,#e0e0e0))}.wp-block-activitypub-followers .followers-container{position:relative}.wp-block-activitypub-followers .followers-container .followers-list{list-style:none;margin:0;padding:0}.wp-block-activitypub-followers .followers-container .follower-item{margin:0 0 8px}.wp-block-activitypub-followers .followers-container .follower-item:last-child{margin-bottom:0}.wp-block-activitypub-followers .followers-container .follower-link{align-items:center;border:none;border-radius:8px;box-shadow:none;display:flex;padding:8px;transition:background-color .2s ease}.wp-block-activitypub-followers .followers-container .follower-link:focus,.wp-block-activitypub-followers .followers-container .follower-link:hover{background-color:var(--wp--preset--color--subtle-background,var(--wp--preset--color--accent-2,var(--wp--preset--color--tertiary,var(--wp--preset--color--secondary,#f0f0f0))));box-shadow:none;outline:none}.wp-block-activitypub-followers .followers-container .follower-link:focus .external-link-icon,.wp-block-activitypub-followers .followers-container .follower-link:hover .external-link-icon{opacity:1}.wp-block-activitypub-followers .followers-container .follower-avatar{border:1px solid #e0e0e0;border-radius:50%;height:48px;margin-right:16px;-o-object-fit:cover;object-fit:cover;width:48px}.wp-block-activitypub-followers .followers-container .follower-info{display:flex;flex:1;flex-direction:column;line-height:1.3;overflow:hidden}.wp-block-activitypub-followers .followers-container .follower-name{font-weight:600;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wp-block-activitypub-followers .followers-container .follower-username{color:var(--wp--preset--color--very-dark-gray,#666);font-size:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wp-block-activitypub-followers .followers-container .external-link-icon{height:16px;margin-left:8px;transition:opacity .2s ease;width:16px}.wp-block-activitypub-followers .followers-container .followers-pagination{align-items:center;border-top-style:solid;border-top-width:1px;display:grid;grid-template-columns:1fr auto 1fr;margin-top:16px;padding-top:8px!important}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-info{color:var(--wp--preset--color--very-dark-gray,#666);font-size:90%;justify-self:center}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next,.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous{border:none;box-shadow:none;cursor:pointer;display:inline-block;font-size:90%;min-width:60px;padding:8px 0}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next[hidden],.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous[hidden]{display:none!important}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next[aria-disabled=true],.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous[aria-disabled=true]{cursor:not-allowed;opacity:.3;pointer-events:none;text-decoration:none}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous{justify-self:start;padding-right:8px}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous:before{content:"←"}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next{justify-self:end;padding-left:8px;text-align:right}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next:after{content:"→"}@media(max-width:480px){.wp-block-activitypub-followers .followers-container .followers-pagination{grid-template-columns:1fr 1fr}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-info{display:none}.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-next,.wp-block-activitypub-followers .followers-container .followers-pagination .pagination-previous{align-items:center;font-size:100%;min-height:44px}}.wp-block-activitypub-followers .followers-container .followers-loading{align-items:center;background-color:hsla(0,0%,100%,.5);border-radius:8px;bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0}.wp-block-activitypub-followers .followers-container .followers-loading[aria-hidden=true]{display:none}.wp-block-activitypub-followers .followers-container .loading-spinner{animation:spin 1s ease-in-out infinite;border:3px solid color-mix(in srgb,var(--wp--preset--color--primary,#0073aa) 30%,transparent);border-radius:50%;border-top-color:var(--wp--preset--color--primary,#0073aa);height:40px;width:40px}@keyframes spin{to{transform:rotate(1turn)}}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block){background-color:var(--wp--preset--color--white,#fff);border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1);box-sizing:border-box;padding:24px}@media(max-width:480px){.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block){margin-left:-12px;margin-right:-12px}}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .wp-block-heading{border-bottom:none;margin-bottom:16px;text-align:center}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .follower-link{border:1px solid #e0e0e0;margin-bottom:8px}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .follower-link:focus,.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .follower-link:hover{border-color:#c7c7c7}.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) .followers-pagination{border:none;padding-bottom:0!important}.wp-block-activitypub-followers.is-style-compact .follower-link{padding:4px}.wp-block-activitypub-followers.is-style-compact .follower-avatar{height:36px;margin-right:8px;width:36px}.wp-block-activitypub-followers.is-style-compact .follower-name{font-size:90%}.wp-block-activitypub-followers.is-style-compact .follower-username{font-size:80%}.wp-block-activitypub-followers.is-style-compact .followers-pagination{margin-top:8px;padding-top:4px}.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-next,.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-previous{font-size:80%;padding-bottom:4px;padding-top:4px}@media(max-width:480px){.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-next,.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-previous{font-size:100%}}.wp-block-activitypub-followers.is-style-compact .followers-pagination .pagination-info{font-size:80%}
diff --git a/build/followers/style-view-rtl.css b/build/blocks/followers/style-view-rtl.css
similarity index 100%
rename from build/followers/style-view-rtl.css
rename to build/blocks/followers/style-view-rtl.css
diff --git a/build/followers/style-view.css b/build/blocks/followers/style-view.css
similarity index 100%
rename from build/followers/style-view.css
rename to build/blocks/followers/style-view.css
diff --git a/build/blocks/followers/view.asset.php b/build/blocks/followers/view.asset.php
new file mode 100644
index 000000000..3859fc72c
--- /dev/null
+++ b/build/blocks/followers/view.asset.php
@@ -0,0 +1 @@
+ array('@wordpress/interactivity'), 'version' => '1ee127d6b44f5125a728', 'type' => 'module');
diff --git a/build/blocks/followers/view.js b/build/blocks/followers/view.js
new file mode 100644
index 000000000..deb5ecd21
--- /dev/null
+++ b/build/blocks/followers/view.js
@@ -0,0 +1 @@
+import*as e from"@wordpress/interactivity";var t={d:(e,r)=>{for(var o in r)t.o(r,o)&&!t.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:r[o]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const r=(l={getContext:()=>e.getContext,store:()=>e.store},c={},t.d(c,l),c),{apiFetch:o,url:a}=window.wp,{actions:n,state:s}=(0,r.store)("activitypub/followers",{state:{get paginationText(){const{page:e,pages:t}=(0,r.getContext)();return`${e} / ${t}`},get disablePreviousLink(){const{page:e}=(0,r.getContext)();return e<=1},get disableNextLink(){const{page:e,pages:t}=(0,r.getContext)();return e>=t}},actions:{async fetchFollowers(){const e=(0,r.getContext)(),{userId:t,page:n,per_page:l,order:c}=e;e.isLoading=!0;try{const r=a.addQueryArgs(`/${s.namespace}/actors/${t}/followers`,{context:"full",per_page:l,order:c,page:n}),{orderedItems:g,totalItems:p}=await o({path:r});e.followers=g.map((e=>({handle:"@"+e.preferredUsername,icon:e.icon,name:e.name||e.preferredUsername,url:e.url||e.id}))),e.total=p,e.pages=Math.ceil(p/l)}catch(e){console.error("Error fetching followers:",e)}finally{e.isLoading=!1}},previousPage(e){e.preventDefault();const t=(0,r.getContext)();t.page>1&&(t.page--,n.fetchFollowers().catch((e=>{console.error("Error fetching followers:",e)})))},nextPage(e){e.preventDefault();const t=(0,r.getContext)();t.page{console.error("Error fetching followers:",e)})))}},callbacks:{setDefaultAvatar(e){e.target.src=s.defaultAvatarUrl}}});var l,c;
\ No newline at end of file
diff --git a/build/blocks/reactions/block.json b/build/blocks/reactions/block.json
new file mode 100644
index 000000000..23bbea380
--- /dev/null
+++ b/build/blocks/reactions/block.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "name": "activitypub/reactions",
+ "apiVersion": 3,
+ "version": "3.0.3",
+ "title": "Fediverse Reactions",
+ "category": "widgets",
+ "icon": "heart",
+ "description": "Display Fediverse likes and reposts",
+ "supports": {
+ "align": [
+ "wide",
+ "full"
+ ],
+ "color": {
+ "gradients": true
+ },
+ "__experimentalBorder": {
+ "radius": true,
+ "width": true,
+ "color": true,
+ "style": true
+ },
+ "html": false,
+ "interactivity": true,
+ "layout": {
+ "default": {
+ "type": "constrained",
+ "orientation": "vertical",
+ "justifyContent": "center"
+ },
+ "allowEditing": false
+ },
+ "shadow": true,
+ "typography": {
+ "fontSize": true,
+ "__experimentalDefaultControls": {
+ "fontSize": true
+ }
+ }
+ },
+ "blockHooks": {
+ "core/post-content": "after"
+ },
+ "textdomain": "activitypub",
+ "editorScript": "file:./index.js",
+ "style": "file:./style-index.css",
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
+ "render": "file:./render.php"
+}
\ No newline at end of file
diff --git a/build/blocks/reactions/index.asset.php b/build/blocks/reactions/index.asset.php
new file mode 100644
index 000000000..15b3bd542
--- /dev/null
+++ b/build/blocks/reactions/index.asset.php
@@ -0,0 +1 @@
+ array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '66d2e9cbcff2fdd6bad4');
diff --git a/build/blocks/reactions/index.js b/build/blocks/reactions/index.js
new file mode 100644
index 000000000..7cec4f28e
--- /dev/null
+++ b/build/blocks/reactions/index.js
@@ -0,0 +1,3 @@
+(()=>{"use strict";var e,t={321:(e,t,r)=>{const a=window.wp.blocks,n=[{attributes:{title:{type:"string",default:"Fediverse reactions"}},supports:{html:!1,color:{gradients:!0,link:!0,__experimentalDefaultControls:{background:!0,text:!0,link:!0}},__experimentalBorder:{radius:!0,width:!0,color:!0,style:!0},typography:{fontSize:!0,__experimentalDefaultControls:{fontSize:!0}}},isEligible:({title:e})=>!!e,migrate:({title:e,...t})=>[t,[(0,a.createBlock)("core/heading",{content:e,level:6})]]}],l=window.React,i=window.wp.blockEditor,o=window.wp.i18n,s=window.wp.data,c=window.wp.element,u=window.wp.components,p=window.wp.apiFetch;var m=r.n(p);function d(){return window._activityPubOptions||{}}const f=({reactions:e})=>{const{defaultAvatarUrl:t}=d();return(0,l.createElement)("ul",{className:"reaction-avatars"},e.map(((e,r)=>{const a=["reaction-avatar"].filter(Boolean).join(" "),n=e.avatar||t;return(0,l.createElement)("li",{key:r},(0,l.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer"},(0,l.createElement)("img",{src:n,alt:e.name,className:a,width:"32",height:"32",onError:e=>{e.target.src=t}})))})))},v=({reactions:e})=>{const{defaultAvatarUrl:t}=d();return(0,l.createElement)("ul",{className:"reactions-list"},e.map(((e,r)=>{const a=e.avatar||t;return(0,l.createElement)("li",{key:r,className:"reaction-item"},(0,l.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,l.createElement)("img",{src:a,alt:e.name,width:"32",height:"32",onError:e=>{e.target.src=t}}),(0,l.createElement)("span",{className:"reaction-name"},e.name)))})))},h=({items:e,label:t})=>{const[r,a]=(0,c.useState)(!1),[n,i]=(0,c.useState)(null),o=(0,c.useRef)(null),s=e.slice(0,20);return(0,l.createElement)("div",{className:"reaction-group",ref:o},(0,l.createElement)(f,{reactions:s}),(0,l.createElement)(u.Button,{ref:i,className:"reaction-label is-link",onClick:()=>a(!r),"aria-expanded":r},t),r&&n&&(0,l.createElement)(u.Popover,{anchor:n,onClose:()=>a(!1)},(0,l.createElement)(v,{reactions:e})))};function w({postId:e=null,reactions:t=null,fallbackReactions:r=null}){const{namespace:a}=d(),[n,i]=(0,c.useState)(t),[o,s]=(0,c.useState)(!t),u=()=>{r&&i(r),s(!1)};return(0,c.useEffect)((()=>{if(t)return i(t),void s(!1);e&&"number"==typeof e?(s(!0),m()({path:`/${a}/posts/${e}/reactions`}).then((e=>{const t=Object.values(e).some((e=>e.items?.length>0));i(!t&&r?r:e),s(!1)})).catch(u)):u()}),[e,t,r,a]),o?null:n&&Object.values(n).some((e=>e.items?.length>0))?(0,l.createElement)(l.Fragment,null,Object.entries(n).map((([e,t])=>t.items?.length?(0,l.createElement)(h,{key:e,items:t.items,label:t.label}):null))):null}const g=(e,t,r,a)=>Array.from({length:e},((e,n)=>({name:`${t} ${n+1}`,url:"#",avatar:`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='32' fill='%23${a[n%a.length]}'/%3E%3Ctext x='32' y='38' font-family='sans-serif' font-size='24' fill='white' text-anchor='middle'%3E${String.fromCharCode(r+n)}%3C/text%3E%3C/svg%3E`}))),b=["FF6B6B","4ECDC4","45B7D1","96CEB4","D4A5A5","9B59B6","3498DB","E67E22"],y={likes:{label:(0,o.sprintf)(/* translators: %d: Number of likes */ /* translators: %d: Number of likes */
+(0,o._x)("%d likes","number of likes","activitypub"),9),items:g(9,"User",65,b)},reposts:{label:(0,o.sprintf)(/* translators: %d: Number of reposts */ /* translators: %d: Number of reposts */
+(0,o._x)("%d reposts","number of reposts","activitypub"),6),items:g(6,"Reposter",82,b)}},E=JSON.parse('{"$schema":"https://schemas.wp.org/trunk/block.json","name":"activitypub/reactions","apiVersion":3,"version":"3.0.3","title":"Fediverse Reactions","category":"widgets","icon":"heart","description":"Display Fediverse likes and reposts","supports":{"align":["wide","full"],"color":{"gradients":true},"__experimentalBorder":{"radius":true,"width":true,"color":true,"style":true},"html":false,"interactivity":true,"layout":{"default":{"type":"constrained","orientation":"vertical","justifyContent":"center"},"allowEditing":false},"shadow":true,"typography":{"fontSize":true,"__experimentalDefaultControls":{"fontSize":true}}},"blockHooks":{"core/post-content":"after"},"textdomain":"activitypub","editorScript":"file:./index.js","style":"file:./style-index.css","viewScriptModule":"file:./view.js","viewScript":"wp-api-fetch","render":"file:./render.php"}');(0,a.registerBlockType)(E,{deprecated:n,edit:function({__unstableLayoutClassNames:e}){const t=(0,i.useBlockProps)({className:e}),{getCurrentPostId:r}=(0,s.select)("core/editor"),a=[["core/heading",{level:6,placeholder:(0,o.__)("Fediverse Reactions","activitypub"),content:(0,o.__)("Fediverse Reactions","activitypub")}]];return(0,l.createElement)("div",{...t},(0,l.createElement)(i.InnerBlocks,{template:a,allowedBlocks:["core/heading"],templateLock:"all",renderAppender:!1}),(0,l.createElement)(w,{postId:r(),fallbackReactions:y}))},save:function(){return(0,l.createElement)("div",{...i.useBlockProps.save()},(0,l.createElement)(i.InnerBlocks.Content,null))}})}},r={};function a(e){var n=r[e];if(void 0!==n)return n.exports;var l=r[e]={exports:{}};return t[e](l,l.exports,a),l.exports}a.m=t,e=[],a.O=(t,r,n,l)=>{if(!r){var i=1/0;for(u=0;u=l)&&Object.keys(a.O).every((e=>a.O[e](r[s])))?r.splice(s--,1):(o=!1,l0&&e[u-1][2]>l;u--)e[u]=e[u-1];e[u]=[r,n,l]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={229:0,345:0};a.O.j=t=>0===e[t];var t=(t,r)=>{var n,l,[i,o,s]=r,c=0;if(i.some((t=>0!==e[t]))){for(n in o)a.o(o,n)&&(a.m[n]=o[n]);if(s)var u=s(a)}for(t&&t(r);ca(321)));n=a.O(n)})();
\ No newline at end of file
diff --git a/build/blocks/reactions/render.php b/build/blocks/reactions/render.php
new file mode 100644
index 000000000..67992c7e0
--- /dev/null
+++ b/build/blocks/reactions/render.php
@@ -0,0 +1,218 @@
+ null ) );
+
+/* @var \WP_Block $block Current block. */
+$block = $block ?? '';
+
+/* @var string $content Block content. */
+$content = $content ?? '';
+
+if ( empty( $content ) ) {
+ // Fallback for v1.0.0 blocks.
+ $_title = $attributes['title'] ?? __( 'Fediverse Reactions', 'activitypub' );
+ $content = '' . esc_html( $_title ) . ' ';
+ unset( $attributes['title'], $attributes['className'] );
+} else {
+ $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
+}
+
+// Get the Post ID from attributes or use the current post.
+$_post_id = $attributes['postId'] ?? get_the_ID();
+
+// Generate a unique ID for the block.
+$block_id = 'activitypub-reactions-block-' . wp_unique_id();
+
+$reactions = array();
+
+foreach ( Comment::get_comment_types() as $_type => $type_object ) {
+ $_comments = get_comments(
+ array(
+ 'post_id' => $_post_id,
+ 'type' => $_type,
+ 'status' => 'approve',
+ 'parent' => 0,
+ )
+ );
+
+ if ( empty( $_comments ) ) {
+ continue;
+ }
+
+ $count = count( $_comments );
+ // phpcs:disable WordPress.WP.I18n
+ $label = sprintf(
+ _n(
+ $type_object['count_single'],
+ $type_object['count_plural'],
+ $count,
+ 'activitypub'
+ ),
+ number_format_i18n( $count )
+ );
+ // phpcs:enable WordPress.WP.I18n
+
+ $reactions[ $_type ] = array(
+ 'label' => $label,
+ 'count' => $count,
+ 'items' => array_map(
+ function ( $comment ) {
+ return array(
+ 'name' => html_entity_decode( $comment->comment_author ),
+ 'url' => $comment->comment_author_url,
+ 'avatar' => get_avatar_url( $comment ),
+ );
+ },
+ $_comments
+ ),
+ );
+}
+
+if ( empty( $reactions ) ) {
+ echo '';
+ return;
+}
+
+// Set up the Interactivity API state.
+wp_interactivity_state(
+ 'activitypub/reactions',
+ array(
+ 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
+ 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
+ 'reactions' => array(
+ $_post_id => $reactions,
+ ),
+ )
+);
+
+// Render a subset of the most recent reactions.
+$reactions = array_map(
+ function ( $reaction ) use ( $attributes ) {
+ $count = 20;
+ if ( 'wide' === $attributes['align'] ) {
+ $count = 40;
+ } elseif ( 'full' === $attributes['align'] ) {
+ $count = 60;
+ }
+
+ $reaction['items'] = array_slice( array_reverse( $reaction['items'] ), 0, $count );
+
+ return $reaction;
+ },
+ $reactions
+);
+
+// Initialize the context for the block.
+$context = array(
+ 'blockId' => $block_id,
+ 'modal' => array(
+ 'isCompact' => true,
+ 'isOpen' => false,
+ 'items' => array(),
+ ),
+ 'postId' => $_post_id,
+ 'reactions' => $reactions,
+);
+
+// Add the block wrapper attributes.
+$wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'id' => $block_id,
+ 'data-wp-interactive' => 'activitypub/reactions',
+ 'data-wp-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
+ 'data-wp-init' => 'callbacks.initReactions',
+ )
+);
+
+ob_start();
+?>
+
+
+
+>
+
+
+
+ $reaction ) :
+ /* translators: %s: reaction type. */
+ $aria_label = sprintf( __( 'View all %s', 'activitypub' ), Comment::get_comment_type_attr( $_type, 'label' ) );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true,
+ 'content' => $modal_content,
+ )
+ );
+ ?>
+
diff --git a/build/blocks/reactions/style-index-rtl.css b/build/blocks/reactions/style-index-rtl.css
new file mode 100644
index 000000000..54a50cff2
--- /dev/null
+++ b/build/blocks/reactions/style-index-rtl.css
@@ -0,0 +1 @@
+body.modal-open{overflow:hidden}.activitypub-modal__overlay{align-items:center;background-color:rgba(0,0,0,.5);bottom:0;color:initial;display:flex;justify-content:center;right:0;padding:1rem;position:fixed;left:0;top:0;z-index:100000}.activitypub-modal__overlay.compact{align-items:flex-start;background-color:transparent;bottom:auto;justify-content:flex-start;right:auto;padding:0;position:absolute;left:auto;top:auto;z-index:100}.activitypub-modal__overlay[hidden]{display:none}.activitypub-modal__frame{animation:activitypub-modal-appear .2s ease-out;background-color:var(--wp--preset--color--white,#fff);border-radius:8px;box-shadow:0 5px 15px rgba(0,0,0,.3);display:flex;flex-direction:column;max-height:calc(100vh - 2rem);max-width:660px;overflow:hidden;width:100%}.compact .activitypub-modal__frame{box-shadow:0 2px 8px rgba(0,0,0,.1);max-height:300px;max-width:-moz-min-content;max-width:min-content;min-width:250px;width:auto}.activitypub-modal__header{align-items:center;border-bottom:1px solid var(--wp--preset--color--light-gray,#f0f0f0);display:flex;flex-shrink:0;justify-content:space-between;padding:2rem 2rem 1.5rem}.compact .activitypub-modal__header{display:none}.activitypub-modal__header .activitypub-modal__close{align-items:center;border:none;cursor:pointer;display:flex;justify-content:center;padding:.5rem;width:auto}.activitypub-modal__header .activitypub-modal__close:active{border:none;padding:.5rem}.activitypub-modal__title{font-size:130%;font-weight:600;line-height:1.4;margin:0!important}.activitypub-modal__content{overflow-y:auto}@keyframes activitypub-modal-appear{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}.wp-block-activitypub-reactions{margin-bottom:2rem;margin-top:2rem;position:relative}.wp-block-activitypub-reactions.has-background,.wp-block-activitypub-reactions.has-border{box-sizing:border-box;padding:2rem}.wp-block-activitypub-reactions .activitypub-reactions{display:flex;flex-direction:column;flex-wrap:wrap}.wp-block-activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75rem;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.wp-block-activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.wp-block-activitypub-reactions .reaction-group .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0!important;padding:0}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li{margin:0 0 0 -10px;padding:0;transition:transform .2s ease}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li:not([hidden]):not(:has(~li:not([hidden]))){margin-left:0}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li:hover{transform:translateY(-2px);z-index:2}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li a{border-radius:50%;box-shadow:none;display:block;line-height:1;text-decoration:none}.wp-block-activitypub-reactions .reaction-group .reaction-avatar{max-height:32px;max-width:32px;overflow:hidden;-moz-force-broken-image-icon:1;border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);transition:transform .6s cubic-bezier(.34,1.56,.64,1);will-change:transform}.wp-block-activitypub-reactions .reaction-group .reaction-avatar:focus-visible,.wp-block-activitypub-reactions .reaction-group .reaction-avatar:hover{position:relative;transform:translateY(-5px);z-index:1}.wp-block-activitypub-reactions .reaction-group .reaction-label{align-items:center;background:none;border:none;border-radius:4px;color:currentColor;display:flex;flex:0 0 auto;font-size:70%;gap:.25rem;padding:.25rem .5rem;text-decoration:none;transition:background-color .2s ease;white-space:nowrap}.wp-block-activitypub-reactions .reaction-group .reaction-label:hover{background-color:rgba(0,0,0,.05);color:currentColor}.wp-block-activitypub-reactions .reaction-group .reaction-label:focus:not(:disabled){box-shadow:none;outline:1px solid currentColor;outline-offset:2px}.reactions-list{list-style:none;margin:0!important;padding:.5rem}.components-popover__content>.reactions-list{padding:0}.reactions-list .reaction-item{margin:0 0 .5rem}.reactions-list .reaction-item:last-child{margin-bottom:0}.reactions-list .reaction-item a{align-items:center;border-radius:4px;box-shadow:none;color:inherit;display:flex;gap:.75rem;padding:.5rem;text-decoration:none;transition:background-color .2s ease}.reactions-list .reaction-item a:hover{background-color:rgba(0,0,0,.03)}.reactions-list .reaction-item img{border:1px solid var(--wp--preset--color--light-gray,#f0f0f0);border-radius:50%;box-shadow:none;height:36px;width:36px}.reactions-list .reaction-item .reaction-name{font-size:75%}.components-popover__content{box-shadow:0 2px 8px rgba(0,0,0,.1);max-height:300px;max-width:-moz-min-content;max-width:min-content;min-width:250px;padding:.5rem;width:auto}
diff --git a/build/blocks/reactions/style-index.css b/build/blocks/reactions/style-index.css
new file mode 100644
index 000000000..5b2fbf3f5
--- /dev/null
+++ b/build/blocks/reactions/style-index.css
@@ -0,0 +1 @@
+body.modal-open{overflow:hidden}.activitypub-modal__overlay{align-items:center;background-color:rgba(0,0,0,.5);bottom:0;color:initial;display:flex;justify-content:center;left:0;padding:1rem;position:fixed;right:0;top:0;z-index:100000}.activitypub-modal__overlay.compact{align-items:flex-start;background-color:transparent;bottom:auto;justify-content:flex-start;left:auto;padding:0;position:absolute;right:auto;top:auto;z-index:100}.activitypub-modal__overlay[hidden]{display:none}.activitypub-modal__frame{animation:activitypub-modal-appear .2s ease-out;background-color:var(--wp--preset--color--white,#fff);border-radius:8px;box-shadow:0 5px 15px rgba(0,0,0,.3);display:flex;flex-direction:column;max-height:calc(100vh - 2rem);max-width:660px;overflow:hidden;width:100%}.compact .activitypub-modal__frame{box-shadow:0 2px 8px rgba(0,0,0,.1);max-height:300px;max-width:-moz-min-content;max-width:min-content;min-width:250px;width:auto}.activitypub-modal__header{align-items:center;border-bottom:1px solid var(--wp--preset--color--light-gray,#f0f0f0);display:flex;flex-shrink:0;justify-content:space-between;padding:2rem 2rem 1.5rem}.compact .activitypub-modal__header{display:none}.activitypub-modal__header .activitypub-modal__close{align-items:center;border:none;cursor:pointer;display:flex;justify-content:center;padding:.5rem;width:auto}.activitypub-modal__header .activitypub-modal__close:active{border:none;padding:.5rem}.activitypub-modal__title{font-size:130%;font-weight:600;line-height:1.4;margin:0!important}.activitypub-modal__content{overflow-y:auto}@keyframes activitypub-modal-appear{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}.wp-block-activitypub-reactions{margin-bottom:2rem;margin-top:2rem;position:relative}.wp-block-activitypub-reactions.has-background,.wp-block-activitypub-reactions.has-border{box-sizing:border-box;padding:2rem}.wp-block-activitypub-reactions .activitypub-reactions{display:flex;flex-direction:column;flex-wrap:wrap}.wp-block-activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75rem;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.wp-block-activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.wp-block-activitypub-reactions .reaction-group .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0!important;padding:0}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li{margin:0 -10px 0 0;padding:0;transition:transform .2s ease}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li:not([hidden]):not(:has(~li:not([hidden]))){margin-right:0}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li:hover{transform:translateY(-2px);z-index:2}.wp-block-activitypub-reactions .reaction-group .reaction-avatars li a{border-radius:50%;box-shadow:none;display:block;line-height:1;text-decoration:none}.wp-block-activitypub-reactions .reaction-group .reaction-avatar{max-height:32px;max-width:32px;overflow:hidden;-moz-force-broken-image-icon:1;border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);transition:transform .6s cubic-bezier(.34,1.56,.64,1);will-change:transform}.wp-block-activitypub-reactions .reaction-group .reaction-avatar:focus-visible,.wp-block-activitypub-reactions .reaction-group .reaction-avatar:hover{position:relative;transform:translateY(-5px);z-index:1}.wp-block-activitypub-reactions .reaction-group .reaction-label{align-items:center;background:none;border:none;border-radius:4px;color:currentColor;display:flex;flex:0 0 auto;font-size:70%;gap:.25rem;padding:.25rem .5rem;text-decoration:none;transition:background-color .2s ease;white-space:nowrap}.wp-block-activitypub-reactions .reaction-group .reaction-label:hover{background-color:rgba(0,0,0,.05);color:currentColor}.wp-block-activitypub-reactions .reaction-group .reaction-label:focus:not(:disabled){box-shadow:none;outline:1px solid currentColor;outline-offset:2px}.reactions-list{list-style:none;margin:0!important;padding:.5rem}.components-popover__content>.reactions-list{padding:0}.reactions-list .reaction-item{margin:0 0 .5rem}.reactions-list .reaction-item:last-child{margin-bottom:0}.reactions-list .reaction-item a{align-items:center;border-radius:4px;box-shadow:none;color:inherit;display:flex;gap:.75rem;padding:.5rem;text-decoration:none;transition:background-color .2s ease}.reactions-list .reaction-item a:hover{background-color:rgba(0,0,0,.03)}.reactions-list .reaction-item img{border:1px solid var(--wp--preset--color--light-gray,#f0f0f0);border-radius:50%;box-shadow:none;height:36px;width:36px}.reactions-list .reaction-item .reaction-name{font-size:75%}.components-popover__content{box-shadow:0 2px 8px rgba(0,0,0,.1);max-height:300px;max-width:-moz-min-content;max-width:min-content;min-width:250px;padding:.5rem;width:auto}
diff --git a/build/blocks/reactions/view.asset.php b/build/blocks/reactions/view.asset.php
new file mode 100644
index 000000000..3c0e9f451
--- /dev/null
+++ b/build/blocks/reactions/view.asset.php
@@ -0,0 +1 @@
+ array('@wordpress/interactivity'), 'version' => '4067078b710ef29f00a0', 'type' => 'module');
diff --git a/build/blocks/reactions/view.js b/build/blocks/reactions/view.js
new file mode 100644
index 000000000..0363abe94
--- /dev/null
+++ b/build/blocks/reactions/view.js
@@ -0,0 +1 @@
+import*as t from"@wordpress/interactivity";var e={d:(t,o)=>{for(var n in o)e.o(o,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:o[n]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const o=(n={getContext:()=>t.getContext,getElement:()=>t.getElement,store:()=>t.store,withScope:()=>t.withScope},a={},e.d(a,n),a);var n,a;const{apiFetch:c}=window.wp;!function(){const{actions:t,callbacks:e}=(0,o.store)("activitypub/reactions",{actions:{openModal(t){const n=(0,o.getContext)();n.modal.isOpen=!0,n.modal.isCompact?setTimeout(e.positionModal,0):setTimeout((()=>{const t=document.getElementById(n.blockId);if(t){const o=t.querySelector(".activitypub-modal__frame");o&&e.trapFocus(o)}}),50),"function"==typeof e.onModalOpen&&e.onModalOpen(t)},closeModal(t){const n=(0,o.getContext)();n.modal.isOpen=!1;const a=(0,o.getElement)();if("actions.toggleModal"===a.ref.dataset["wpOn-Click"])a.ref.focus();else{const t=document.getElementById(n.blockId);if(t){const e=t.querySelector('[data-wp-on--click="actions.toggleModal"], [data-wp-on-async--click="actions.toggleModal"]');e&&e.focus()}}"function"==typeof e.onModalClose&&e.onModalClose(t)},toggleModal(e){const{modal:n}=(0,o.getContext)();n.isOpen?t.closeModal(e):t.openModal(e)}},callbacks:{_abortController:null,handleModalEffects(){const{modal:t}=(0,o.getContext)();if(t.isOpen&&!t.isCompact?document.body.classList.add("modal-open"):document.body.classList.remove("modal-open"),e._abortController&&(e._abortController.abort(),e._abortController=null),t.isOpen){e._abortController=new AbortController;const{signal:t}=e._abortController;document.addEventListener("keydown",e.documentKeydown,{signal:t}),document.addEventListener("click",e.documentClick,{signal:t})}},documentKeydown(e){const{modal:n}=(0,o.getContext)();n.isOpen&&"Escape"===e.key&&t.closeModal()},documentClick(e){const{blockId:n,modal:a}=(0,o.getContext)();if(!a.isOpen)return;const c=document.getElementById(n);if(!c)return;const l=c.querySelector('.wp-element-button[data-wp-on--click="actions.toggleModal"]');if(l&&(l===e.target||l.contains(e.target)))return;const s=c.querySelector(".activitypub-modal__frame");s&&!s.contains(e.target)&&t.closeModal()},positionModal(){const{blockId:t}=(0,o.getContext)(),e=document.getElementById(t);if(!e)return;const n=e.querySelector(".activitypub-modal__overlay");if(!n)return;n.style.top="",n.style.left="",n.style.right="",n.style.bottom="";const a=(0,o.getElement)().ref.getBoundingClientRect(),c=window.innerWidth,l=e.getBoundingClientRect();let s={top:a.bottom-l.top+8+"px",left:a.left-l.left-2+"px"};c-a.right<250&&(s.left="auto",s.right=l.right-a.right+"px"),Object.assign(n.style,s)},trapFocus(t){const e=t.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]):not([readonly]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])'),o=e[0],n=e[e.length-1];o&&o.classList.contains("activitypub-modal__close")&&e.length>1?e[1].focus():o.focus(),t.addEventListener("keydown",(function(t){"Tab"!==t.key&&9!==t.keyCode||(t.shiftKey?document.activeElement===o&&(n.focus(),t.preventDefault()):document.activeElement===n&&(o.focus(),t.preventDefault()))}))}}})}();const{callbacks:l,state:s}=(0,o.store)("activitypub/reactions",{actions:{async fetchReactions(){const t=(0,o.getContext)(),{namespace:e}=s;if(t.postId)try{t.reactions=await c({path:`/${e}/posts/${t.postId}/reactions`})}catch(t){console.error("Error fetching reactions:",t)}}},callbacks:{initReactions(){const t=new ResizeObserver((0,o.withScope)(l.calculateVisibleAvatars));return(0,o.getElement)().ref.querySelectorAll(".reaction-group").forEach((e=>{t.observe(e)})),()=>{t.disconnect()}},calculateVisibleAvatars(){const{postId:t}=(0,o.getContext)();(s.reactions&&s.reactions[t]?Object.keys(s.reactions[t]):[]).forEach((e=>{s.reactions?.[t][e]?.items?.length&&(0,o.getElement)().ref.querySelectorAll(`.reaction-group[data-reaction-type="${e}"]`).forEach((o=>{const n=o.querySelector(".reaction-label").offsetWidth||0,a=o.offsetWidth-n-12;let c=1;a>32&&(c+=Math.floor((a-32)/22));const l=s.reactions[t][e].items,r=Math.min(c,l.length),i=o.querySelector(".reaction-avatars");i&&i.querySelectorAll("li").forEach(((t,e)=>{e array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '5a6a24d679d205a8f709');
+ array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '57bbe00f94168db2cc91');
diff --git a/build/remote-reply/index.js b/build/blocks/remote-reply/index.js
similarity index 93%
rename from build/remote-reply/index.js
rename to build/blocks/remote-reply/index.js
index 601228ce4..083ee5d33 100644
--- a/build/remote-reply/index.js
+++ b/build/blocks/remote-reply/index.js
@@ -1,2 +1,2 @@
-(()=>{"use strict";var e,t={20:(e,t,r)=>{var o=r(609),a=Symbol.for("react.element"),i=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),l=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,n={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var o,c={},s=null,m=null;for(o in void 0!==r&&(s=""+r),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(m=t.ref),t)i.call(t,o)&&!n.hasOwnProperty(o)&&(c[o]=t[o]);if(e&&e.defaultProps)for(o in t=e.defaultProps)void 0===c[o]&&(c[o]=t[o]);return{$$typeof:a,type:e,key:s,ref:m,props:c,_owner:l.current}}},170:(e,t,r)=>{var o=r(609);const a=window.wp.element,i=window.wp.domReady;var l=r.n(i);const n=window.wp.components,c=window.wp.i18n,s=(0,a.forwardRef)((function({icon:e,size:t=24,...r},o){return(0,a.cloneElement)(e,{width:t,height:t,...r,ref:o})})),m=window.wp.primitives;var p=r(848);const u=(0,p.jsx)(m.SVG,{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:(0,p.jsx)(m.Path,{d:"M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z"})}),d=window.wp.apiFetch;var v=r.n(d);const y=(0,p.jsx)(m.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,p.jsx)(m.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5 4.5h11a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM3 5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm17 3v10.75c0 .69-.56 1.25-1.25 1.25H6v1.5h12.75a2.75 2.75 0 0 0 2.75-2.75V8H20Z"})}),_=(0,p.jsx)(m.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,p.jsx)(m.Path,{d:"M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z"})}),f=(0,p.jsx)(m.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,p.jsx)(m.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),b=window.wp.compose,w="fediverse-remote-user";function h(){const[e,t]=(0,a.useState)(function(){const e=localStorage.getItem(w);return e?JSON.parse(e):{}}()),r=(0,a.useCallback)((e=>{!function(e){localStorage.setItem(w,JSON.stringify(e))}(e),t(e)}),[]),o=(0,a.useCallback)((()=>{localStorage.removeItem(w),t({})}),[]);return{template:e?.template||!1,profileURL:e?.profileURL||!1,setRemoteUser:r,deleteRemoteUser:o}}function g(e){try{return new URL(e),!0}catch(e){return!1}}function E({actionText:e,copyDescription:t,handle:r,resourceUrl:i,myProfile:l="",rememberProfile:m=!1}){const p=(0,c.__)("Loading...","activitypub"),u=(0,c.__)("Opening...","activitypub"),d=(0,c.__)("Error","activitypub"),w=(0,c.__)("Invalid","activitypub"),E=l||(0,c.__)("My Profile","activitypub"),[C,R]=(0,a.useState)(e),[x,O]=(0,a.useState)(y),k=(0,b.useCopyToClipboard)(r,(()=>{O(_),setTimeout((()=>O(y)),1e3)})),[L,S]=(0,a.useState)(""),[U,N]=(0,a.useState)(!0),{setRemoteUser:P}=h(),j=(0,a.useCallback)((()=>{let t;if(!g(L)&&!function(e){const t=e.replace(/^@/,"").split("@");return 2===t.length&&g(`https://${t[1]}`)}(L))return R(w),t=setTimeout((()=>R(e)),2e3),()=>clearTimeout(t);const r=i+L;R(p),v()({path:r}).then((({url:t,template:r})=>{U&&P({profileURL:L,template:r}),R(u),setTimeout((()=>{window.open(t,"_blank"),R(e)}),200)})).catch((()=>{R(d),setTimeout((()=>R(e)),2e3)}))}),[L]);return(0,o.createElement)("div",{className:"activitypub__dialog",role:"dialog","aria-labelledby":"dialog-title"},(0,o.createElement)("div",{className:"activitypub-dialog__section"},(0,o.createElement)("h4",{id:"dialog-title"},E),(0,o.createElement)("div",{className:"activitypub-dialog__description",id:"copy-description"},t),(0,o.createElement)("div",{className:"activitypub-dialog__button-group"},(0,o.createElement)("label",{htmlFor:"profile-handle",className:"screen-reader-text"},t),(0,o.createElement)("input",{type:"text",id:"profile-handle",value:r,readOnly:!0}),(0,o.createElement)(n.Button,{ref:k,"aria-label":(0,c.__)("Copy handle to clipboard","activitypub")},(0,o.createElement)(s,{icon:x}),(0,c.__)("Copy","activitypub")))),(0,o.createElement)("div",{className:"activitypub-dialog__section"},(0,o.createElement)("h4",{id:"remote-profile-title"},(0,c.__)("Your Profile","activitypub")),(0,o.createElement)("div",{className:"activitypub-dialog__description",id:"remote-profile-description"},(0,a.createInterpolateElement)((0,c.__)("Or, if you know your own profile, we can start things that way! (eg @yourusername@example.com
)","activitypub"),{code:(0,o.createElement)("code",null)})),(0,o.createElement)("div",{className:"activitypub-dialog__button-group"},(0,o.createElement)("label",{htmlFor:"remote-profile",className:"screen-reader-text"},(0,c.__)("Enter your ActivityPub profile","activitypub")),(0,o.createElement)("input",{type:"text",id:"remote-profile",value:L,onKeyDown:e=>{"Enter"===e?.code&&j()},onChange:e=>S(e.target.value),"aria-invalid":C===w}),(0,o.createElement)(n.Button,{onClick:j,"aria-label":(0,c.__)("Submit profile","activitypub")},(0,o.createElement)(s,{icon:f}),C)),m&&(0,o.createElement)("div",{className:"activitypub-dialog__remember"},(0,o.createElement)(n.CheckboxControl,{checked:U,label:(0,c.__)("Remember me for easier comments","activitypub"),onChange:()=>{N(!U)}}))))}function C({selectedComment:e,commentId:t}){const{namespace:r}=window._activityPubOptions||{},a=(0,c.__)("Reply","activitypub"),i=`/${r}/comments/${t}/remote-reply?resource=`,l=(0,c.__)("Copy and paste the Comment URL into the search field of your favorite fediverse app or server.","activitypub");return(0,o.createElement)(E,{actionText:a,copyDescription:l,handle:e,resourceUrl:i,myProfile:(0,c.__)("Original Comment URL","activitypub"),rememberProfile:!0})}function R({profileURL:e,template:t,commentURL:r,deleteRemoteUser:a}){return(0,o.createElement)(o.Fragment,null,(0,o.createElement)(n.Button,{variant:"link",className:"comment-reply-link activitypub-remote-reply__button",onClick:()=>{const e=t.replace("{uri}",r);window.open(e,"_blank")}},/* translators: %s: profile name */ /* translators: %s: profile name */
-(0,c.sprintf)((0,c.__)("Reply as %s","activitypub"),e)),(0,o.createElement)(n.Button,{className:"activitypub-remote-profile-delete",onClick:a,title:(0,c.__)("Delete Remote Profile","activitypub")},(0,o.createElement)(s,{icon:u,size:18})))}function x({selectedComment:e,commentId:t}){const[r,i]=(0,a.useState)(!1),l=(0,c.__)("Remote Reply","activitypub"),{profileURL:s,template:m,deleteRemoteUser:p}=h(),u=s&&m;return(0,o.createElement)(o.Fragment,null,u?(0,o.createElement)(R,{profileURL:s,template:m,commentURL:e,deleteRemoteUser:p}):(0,o.createElement)(n.Button,{variant:"link",className:"comment-reply-link activitypub-remote-reply__button",onClick:()=>i(!0)},(0,c.__)("Reply on the Fediverse","activitypub")),r&&(0,o.createElement)(n.Modal,{className:"activitypub-remote-reply__modal activitypub__modal",onRequestClose:()=>i(!1),title:l},(0,o.createElement)(C,{selectedComment:e,commentId:t})))}let O=1;l()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-remote-reply"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,a.createRoot)(e).render((0,o.createElement)(x,{...t,id:"activitypub-remote-reply-link-"+O++,useId:!0}))}))}))},609:e=>{e.exports=window.React},848:(e,t,r)=>{e.exports=r(20)}},r={};function o(e){var a=r[e];if(void 0!==a)return a.exports;var i=r[e]={exports:{}};return t[e](i,i.exports,o),i.exports}o.m=t,e=[],o.O=(t,r,a,i)=>{if(!r){var l=1/0;for(m=0;m=i)&&Object.keys(o.O).every((e=>o.O[e](r[c])))?r.splice(c--,1):(n=!1,i0&&e[m-1][2]>i;m--)e[m]=e[m-1];e[m]=[r,a,i]},o.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={227:0,739:0};o.O.j=t=>0===e[t];var t=(t,r)=>{var a,i,[l,n,c]=r,s=0;if(l.some((t=>0!==e[t]))){for(a in n)o.o(n,a)&&(o.m[a]=n[a]);if(c)var m=c(o)}for(t&&t(r);so(170)));a=o.O(a)})();
\ No newline at end of file
+(()=>{"use strict";var e,t={20:(e,t,r)=>{var o=r(609),a=Symbol.for("react.element"),i=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),l=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,n={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var o,c={},s=null,m=null;for(o in void 0!==r&&(s=""+r),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(m=t.ref),t)i.call(t,o)&&!n.hasOwnProperty(o)&&(c[o]=t[o]);if(e&&e.defaultProps)for(o in t=e.defaultProps)void 0===c[o]&&(c[o]=t[o]);return{$$typeof:a,type:e,key:s,ref:m,props:c,_owner:l.current}}},146:(e,t,r)=>{var o=r(609);const a=window.wp.element,i=window.wp.domReady;var l=r.n(i);const n=window.wp.components,c=window.wp.i18n,s=(0,a.forwardRef)((function({icon:e,size:t=24,...r},o){return(0,a.cloneElement)(e,{width:t,height:t,...r,ref:o})})),m=window.wp.primitives;var p=r(848);const u=(0,p.jsx)(m.SVG,{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:(0,p.jsx)(m.Path,{d:"M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z"})}),d=window.wp.apiFetch;var v=r.n(d);const y=(0,p.jsx)(m.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,p.jsx)(m.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5 4.5h11a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM3 5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm17 3v10.75c0 .69-.56 1.25-1.25 1.25H6v1.5h12.75a2.75 2.75 0 0 0 2.75-2.75V8H20Z"})}),_=(0,p.jsx)(m.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,p.jsx)(m.Path,{d:"M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z"})}),f=(0,p.jsx)(m.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,p.jsx)(m.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),b=window.wp.compose,w="fediverse-remote-user";function h(){const[e,t]=(0,a.useState)(function(){const e=localStorage.getItem(w);return e?JSON.parse(e):{}}()),r=(0,a.useCallback)((e=>{!function(e){localStorage.setItem(w,JSON.stringify(e))}(e),t(e)}),[]),o=(0,a.useCallback)((()=>{localStorage.removeItem(w),t({})}),[]);return{template:e?.template||!1,profileURL:e?.profileURL||!1,setRemoteUser:r,deleteRemoteUser:o}}function g(e){try{return new URL(e),!0}catch(e){return!1}}function E({actionText:e,copyDescription:t,handle:r,resourceUrl:i,myProfile:l="",rememberProfile:m=!1}){const p=(0,c.__)("Loading...","activitypub"),u=(0,c.__)("Opening...","activitypub"),d=(0,c.__)("Error","activitypub"),w=(0,c.__)("Invalid","activitypub"),E=l||(0,c.__)("My Profile","activitypub"),[C,R]=(0,a.useState)(e),[x,O]=(0,a.useState)(y),k=(0,b.useCopyToClipboard)(r,(()=>{O(_),setTimeout((()=>O(y)),1e3)})),[L,S]=(0,a.useState)(""),[U,N]=(0,a.useState)(!0),{setRemoteUser:P}=h(),j=(0,a.useCallback)((()=>{let t;if(!g(L)&&!function(e){const t=e.replace(/^@/,"").split("@");return 2===t.length&&g(`https://${t[1]}`)}(L))return R(w),t=setTimeout((()=>R(e)),2e3),()=>clearTimeout(t);const r=i+L;R(p),v()({path:r}).then((({url:t,template:r})=>{U&&P({profileURL:L,template:r}),R(u),setTimeout((()=>{window.open(t,"_blank"),R(e)}),200)})).catch((()=>{R(d),setTimeout((()=>R(e)),2e3)}))}),[L]);return(0,o.createElement)("div",{className:"activitypub__dialog",role:"dialog","aria-labelledby":"dialog-title"},(0,o.createElement)("div",{className:"activitypub-dialog__section"},(0,o.createElement)("h4",{id:"dialog-title"},E),(0,o.createElement)("div",{className:"activitypub-dialog__description",id:"copy-description"},t),(0,o.createElement)("div",{className:"activitypub-dialog__button-group"},(0,o.createElement)("label",{htmlFor:"profile-handle",className:"screen-reader-text"},t),(0,o.createElement)("input",{type:"text",id:"profile-handle",value:r,readOnly:!0}),(0,o.createElement)(n.Button,{ref:k,"aria-label":(0,c.__)("Copy handle to clipboard","activitypub")},(0,o.createElement)(s,{icon:x}),(0,c.__)("Copy","activitypub")))),(0,o.createElement)("div",{className:"activitypub-dialog__section"},(0,o.createElement)("h4",{id:"remote-profile-title"},(0,c.__)("Your Profile","activitypub")),(0,o.createElement)("div",{className:"activitypub-dialog__description",id:"remote-profile-description"},(0,a.createInterpolateElement)((0,c.__)("Or, if you know your own profile, we can start things that way! (eg @yourusername@example.com
)","activitypub"),{code:(0,o.createElement)("code",null)})),(0,o.createElement)("div",{className:"activitypub-dialog__button-group"},(0,o.createElement)("label",{htmlFor:"remote-profile",className:"screen-reader-text"},(0,c.__)("Enter your ActivityPub profile","activitypub")),(0,o.createElement)("input",{type:"text",id:"remote-profile",value:L,onKeyDown:e=>{"Enter"===e?.code&&j()},onChange:e=>S(e.target.value),"aria-invalid":C===w}),(0,o.createElement)(n.Button,{onClick:j,"aria-label":(0,c.__)("Submit profile","activitypub")},(0,o.createElement)(s,{icon:f}),C)),m&&(0,o.createElement)("div",{className:"activitypub-dialog__remember"},(0,o.createElement)(n.CheckboxControl,{checked:U,label:(0,c.__)("Remember me for easier comments","activitypub"),onChange:()=>{N(!U)}}))))}function C({selectedComment:e,commentId:t}){const{namespace:r}=window._activityPubOptions||{},a=(0,c.__)("Reply","activitypub"),i=`/${r}/comments/${t}/remote-reply?resource=`,l=(0,c.__)("Copy and paste the Comment URL into the search field of your favorite fediverse app or server.","activitypub");return(0,o.createElement)(E,{actionText:a,copyDescription:l,handle:e,resourceUrl:i,myProfile:(0,c.__)("Original Comment URL","activitypub"),rememberProfile:!0})}function R({profileURL:e,template:t,commentURL:r,deleteRemoteUser:a}){return(0,o.createElement)(o.Fragment,null,(0,o.createElement)(n.Button,{variant:"link",className:"comment-reply-link activitypub-remote-reply__button",onClick:()=>{const e=t.replace("{uri}",r);window.open(e,"_blank")}},/* translators: %s: profile name */ /* translators: %s: profile name */
+(0,c.sprintf)((0,c.__)("Reply as %s","activitypub"),e)),(0,o.createElement)(n.Button,{className:"activitypub-remote-profile-delete",onClick:a,title:(0,c.__)("Delete Remote Profile","activitypub")},(0,o.createElement)(s,{icon:u,size:18})))}function x({selectedComment:e,commentId:t}){const[r,i]=(0,a.useState)(!1),l=(0,c.__)("Remote Reply","activitypub"),{profileURL:s,template:m,deleteRemoteUser:p}=h(),u=s&&m;return(0,o.createElement)(o.Fragment,null,u?(0,o.createElement)(R,{profileURL:s,template:m,commentURL:e,deleteRemoteUser:p}):(0,o.createElement)(n.Button,{variant:"link",className:"comment-reply-link activitypub-remote-reply__button",onClick:()=>i(!0)},(0,c.__)("Reply on the Fediverse","activitypub")),r&&(0,o.createElement)(n.Modal,{className:"activitypub-remote-reply__modal activitypub__modal",onRequestClose:()=>i(!1),title:l},(0,o.createElement)(C,{selectedComment:e,commentId:t})))}let O=1;l()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-remote-reply"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,a.createRoot)(e).render((0,o.createElement)(x,{...t,id:"activitypub-remote-reply-link-"+O++,useId:!0}))}))}))},609:e=>{e.exports=window.React},848:(e,t,r)=>{e.exports=r(20)}},r={};function o(e){var a=r[e];if(void 0!==a)return a.exports;var i=r[e]={exports:{}};return t[e](i,i.exports,o),i.exports}o.m=t,e=[],o.O=(t,r,a,i)=>{if(!r){var l=1/0;for(m=0;m=i)&&Object.keys(o.O).every((e=>o.O[e](r[c])))?r.splice(c--,1):(n=!1,i0&&e[m-1][2]>i;m--)e[m]=e[m-1];e[m]=[r,a,i]},o.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={284:0,4:0};o.O.j=t=>0===e[t];var t=(t,r)=>{var a,i,[l,n,c]=r,s=0;if(l.some((t=>0!==e[t]))){for(a in n)o.o(n,a)&&(o.m[a]=n[a]);if(c)var m=c(o)}for(t&&t(r);so(146)));a=o.O(a)})();
\ No newline at end of file
diff --git a/build/blocks/remote-reply/render.php b/build/blocks/remote-reply/render.php
new file mode 100644
index 000000000..2e7026c90
--- /dev/null
+++ b/build/blocks/remote-reply/render.php
@@ -0,0 +1,192 @@
+ ACTIVITYPUB_REST_NAMESPACE,
+ 'i18n' => array(
+ 'copied' => __( 'Copied!', 'activitypub' ),
+ 'copy' => __( 'Copy', 'activitypub' ),
+ 'emptyProfileError' => __( 'Please enter a profile URL or handle.', 'activitypub' ),
+ 'genericError' => __( 'An error occurred. Please try again.', 'activitypub' ),
+ 'invalidProfileError' => __( 'Please enter a valid URL or handle.', 'activitypub' ),
+ ),
+ )
+);
+
+// Add the block wrapper attributes.
+$wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'id' => $block_id,
+ 'class' => 'activitypub-remote-reply reply',
+ 'data-wp-interactive' => 'activitypub/remote-reply',
+ 'data-wp-init' => 'callbacks.init',
+ )
+);
+
+$wrapper_context = wp_interactivity_data_wp_context(
+ array(
+ 'blockId' => $block_id,
+ 'commentId' => $comment_id,
+ 'commentURL' => $selected_comment,
+ 'copyButtonText' => $state['i18n']['copy'],
+ 'errorMessage' => '',
+ 'hasRemoteUser' => false,
+ 'isError' => false,
+ 'isLoading' => false,
+ 'modal' => array( 'isOpen' => false ),
+ 'profileURL' => '',
+ 'remoteProfile' => '',
+ 'shouldSaveProfile' => true,
+ 'template' => '',
+ )
+);
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+>
+
+
+
+
+ __( 'Remote Reply', 'activitypub' ),
+ 'content' => $modal_content,
+ )
+ );
+ ?>
+
+ array('@wordpress/interactivity'), 'version' => '95d693d7cf19305bfcbb', 'type' => 'module');
diff --git a/build/blocks/remote-reply/view.js b/build/blocks/remote-reply/view.js
new file mode 100644
index 000000000..aa72b8d31
--- /dev/null
+++ b/build/blocks/remote-reply/view.js
@@ -0,0 +1 @@
+import*as e from"@wordpress/interactivity";var t,o,r={580:(t,o,r)=>{const n=(l={getContext:()=>e.getContext,getElement:()=>e.getElement,store:()=>e.store},s={},r.d(s,l),s);var l,s;const{apiFetch:a}=window.wp;!function(){const{actions:e,callbacks:t}=(0,n.store)("activitypub/remote-reply",{actions:{openModal(e){const o=(0,n.getContext)();o.modal.isOpen=!0,o.modal.isCompact?setTimeout(t.positionModal,0):setTimeout((()=>{const e=document.getElementById(o.blockId);if(e){const o=e.querySelector(".activitypub-modal__frame");o&&t.trapFocus(o)}}),50),"function"==typeof t.onModalOpen&&t.onModalOpen(e)},closeModal(e){const o=(0,n.getContext)();o.modal.isOpen=!1;const r=(0,n.getElement)();if("actions.toggleModal"===r.ref.dataset["wpOn-Click"])r.ref.focus();else{const e=document.getElementById(o.blockId);if(e){const t=e.querySelector('[data-wp-on--click="actions.toggleModal"], [data-wp-on-async--click="actions.toggleModal"]');t&&t.focus()}}"function"==typeof t.onModalClose&&t.onModalClose(e)},toggleModal(t){const{modal:o}=(0,n.getContext)();o.isOpen?e.closeModal(t):e.openModal(t)}},callbacks:{_abortController:null,handleModalEffects(){const{modal:e}=(0,n.getContext)();if(e.isOpen&&!e.isCompact?document.body.classList.add("modal-open"):document.body.classList.remove("modal-open"),t._abortController&&(t._abortController.abort(),t._abortController=null),e.isOpen){t._abortController=new AbortController;const{signal:e}=t._abortController;document.addEventListener("keydown",t.documentKeydown,{signal:e}),document.addEventListener("click",t.documentClick,{signal:e})}},documentKeydown(t){const{modal:o}=(0,n.getContext)();o.isOpen&&"Escape"===t.key&&e.closeModal()},documentClick(t){const{blockId:o,modal:r}=(0,n.getContext)();if(!r.isOpen)return;const l=document.getElementById(o);if(!l)return;const s=l.querySelector('.wp-element-button[data-wp-on--click="actions.toggleModal"]');if(s&&(s===t.target||s.contains(t.target)))return;const a=l.querySelector(".activitypub-modal__frame");a&&!a.contains(t.target)&&e.closeModal()},positionModal(){const{blockId:e}=(0,n.getContext)(),t=document.getElementById(e);if(!t)return;const o=t.querySelector(".activitypub-modal__overlay");if(!o)return;o.style.top="",o.style.left="",o.style.right="",o.style.bottom="";const r=(0,n.getElement)().ref.getBoundingClientRect(),l=window.innerWidth,s=t.getBoundingClientRect();let a={top:r.bottom-s.top+8+"px",left:r.left-s.left-2+"px"};l-r.right<250&&(a.left="auto",a.right=s.right-r.right+"px"),Object.assign(o.style,a)},trapFocus(e){const t=e.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]):not([readonly]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])'),o=t[0],r=t[t.length-1];o&&o.classList.contains("activitypub-modal__close")&&t.length>1?t[1].focus():o.focus(),e.addEventListener("keydown",(function(e){"Tab"!==e.key&&9!==e.keyCode||(e.shiftKey?document.activeElement===o&&(r.focus(),e.preventDefault()):document.activeElement===r&&(o.focus(),e.preventDefault()))}))}}})}();const{state:i,actions:c,callbacks:d}=(0,n.store)("activitypub/remote-reply",{state:{get remoteProfileUrl(){const{commentURL:e,template:t}=(0,n.getContext)();return t.replace("{uri}",encodeURIComponent(e))}},actions:{onReplyLinkKeydown(e){"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),c.toggleModal(e))},copyToClipboard(){const e=(0,n.getContext)();navigator.clipboard.writeText(e.commentURL).then((()=>{e.copyButtonText=i.i18n.copied,setTimeout((()=>{e.copyButtonText=i.i18n.copy}),1e3)}),(e=>{console.error("Could not copy text: ",e)}))},updateRemoteProfile(e){const t=(0,n.getContext)();t.remoteProfile=e.target.value,t.isError=!1,t.errorMessage=""},onInputKeydown(e){if("Enter"===e.key)return e.preventDefault(),c.submitRemoteProfile()},*submitRemoteProfile(){const e=(0,n.getContext)(),{namespace:t,i18n:o}=i,r=e.remoteProfile.trim();if(!r)return e.isError=!0,void(e.errorMessage=o.emptyProfileError);if(!d.isHandle(r)&&!d.isUrl(r))return e.isError=!0,void(e.errorMessage=o.invalidProfileError);e.isLoading=!0,e.isError=!1,e.errorMessage="";const l=`/${t}/comments/${e.commentId}/remote-reply?resource=${encodeURIComponent(r)}`;try{const{template:t,url:o}=yield a({path:l});e.isLoading=!1,window.open(o,"_blank"),c.closeModal(),e.shouldSaveProfile&&(d.setStore({profileURL:r,template:t}),Object.assign(e,{hasRemoteUser:!0,profileURL:r,template:t}))}catch(t){console.error("Error submitting profile:",t),e.isLoading=!1,e.isError=!0,e.errorMessage=t.message||o.genericError}},toggleRememberProfile(){const e=(0,n.getContext)();e.shouldSaveProfile=!e.shouldSaveProfile},deleteRemoteUser(){const e=(0,n.getContext)();d.deleteStore(),e.hasRemoteUser=!1,e.profileURL="",e.template=""}},callbacks:{storageKey:"fediverse-remote-user",init(){const e=(0,n.getContext)(),{profileURL:t,template:o}=d.getStore();t&&o&&Object.assign(e,{hasRemoteUser:!0,profileURL:t,template:o})},getStore(){const e=localStorage.getItem(d.storageKey);return e?JSON.parse(e):{}},setStore(e){localStorage.setItem(d.storageKey,JSON.stringify(e))},deleteStore(){localStorage.removeItem(d.storageKey)},isHandle(e){const t=e.replace(/^@/,"").split("@");return 2===t.length&&d.isUrl(`https://${t[1]}`)},isUrl(e){try{return new URL(e),!0}catch(e){return!1}}}})}},n={};function l(e){var t=n[e];if(void 0!==t)return t.exports;var o=n[e]={exports:{}};return r[e](o,o.exports,l),o.exports}l.m=r,t=[],l.O=(e,o,r,n)=>{if(!o){var s=1/0;for(d=0;d=n)&&Object.keys(l.O).every((e=>l.O[e](o[i])))?o.splice(i--,1):(a=!1,n0&&t[d-1][2]>n;d--)t[d]=t[d-1];t[d]=[o,r,n]},l.d=(e,t)=>{for(var o in t)l.o(t,o)&&!l.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),o={915:0,771:0},l.O.j=e=>0===o[e];var s=l.O(void 0,[771],(()=>l(580)));s=l.O(s);
\ No newline at end of file
diff --git a/build/reply-intent/block.json b/build/blocks/reply-intent/block.json
similarity index 100%
rename from build/reply-intent/block.json
rename to build/blocks/reply-intent/block.json
diff --git a/build/reply-intent/plugin.asset.php b/build/blocks/reply-intent/plugin.asset.php
similarity index 100%
rename from build/reply-intent/plugin.asset.php
rename to build/blocks/reply-intent/plugin.asset.php
diff --git a/build/reply-intent/plugin.js b/build/blocks/reply-intent/plugin.js
similarity index 100%
rename from build/reply-intent/plugin.js
rename to build/blocks/reply-intent/plugin.js
diff --git a/build/reply/block.json b/build/blocks/reply/block.json
similarity index 100%
rename from build/reply/block.json
rename to build/blocks/reply/block.json
diff --git a/build/reply/index-rtl.css b/build/blocks/reply/index-rtl.css
similarity index 75%
rename from build/reply/index-rtl.css
rename to build/blocks/reply/index-rtl.css
index 44552c85b..72e7d4505 100644
--- a/build/reply/index-rtl.css
+++ b/build/blocks/reply/index-rtl.css
@@ -1 +1 @@
-.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6}.activitypub-embed-content .ap-preview img{display:block;height:auto}.activitypub-embed-content .ap-preview{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0 0;min-height:64px;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.activitypub-embed-content .ap-preview.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}.activitypub-embed-content .ap-preview.layout-3>img:first-child{grid-row:span 2}.activitypub-embed-content .ap-preview img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview audio,.activitypub-embed-content .ap-preview video{display:block;grid-column:1/span 2;max-width:100%}.activitypub-embed-content .ap-preview audio{width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta span.ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}
+.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6}.activitypub-embed-content .ap-preview img{display:block;height:auto}.activitypub-embed-content .ap-preview{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0 0;min-height:64px;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.activitypub-embed-content .ap-preview.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}.activitypub-embed-content .ap-preview.layout-3>img:first-child{grid-row:span 2}.activitypub-embed-content .ap-preview img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview audio,.activitypub-embed-content .ap-preview video{display:block;grid-column:1/span 2;max-width:100%}.activitypub-embed-content .ap-preview audio{width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta span.ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}
diff --git a/build/reply/index.asset.php b/build/blocks/reply/index.asset.php
similarity index 82%
rename from build/reply/index.asset.php
rename to build/blocks/reply/index.asset.php
index 9f4117fee..9f743df2d 100644
--- a/build/reply/index.asset.php
+++ b/build/blocks/reply/index.asset.php
@@ -1 +1 @@
- array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '78dbc26b5e405051df4a');
+ array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '4b89612674860b9394e5');
diff --git a/build/reply/index.css b/build/blocks/reply/index.css
similarity index 75%
rename from build/reply/index.css
rename to build/blocks/reply/index.css
index 44552c85b..72e7d4505 100644
--- a/build/reply/index.css
+++ b/build/blocks/reply/index.css
@@ -1 +1 @@
-.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6}.activitypub-embed-content .ap-preview img{display:block;height:auto}.activitypub-embed-content .ap-preview{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0 0;min-height:64px;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.activitypub-embed-content .ap-preview.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}.activitypub-embed-content .ap-preview.layout-3>img:first-child{grid-row:span 2}.activitypub-embed-content .ap-preview img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview audio,.activitypub-embed-content .ap-preview video{display:block;grid-column:1/span 2;max-width:100%}.activitypub-embed-content .ap-preview audio{width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta span.ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}
+.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6}.activitypub-embed-content .ap-preview img{display:block;height:auto}.activitypub-embed-content .ap-preview{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0 0;min-height:64px;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.activitypub-embed-content .ap-preview.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}.activitypub-embed-content .ap-preview.layout-3>img:first-child{grid-row:span 2}.activitypub-embed-content .ap-preview img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview audio,.activitypub-embed-content .ap-preview video{display:block;grid-column:1/span 2;max-width:100%}.activitypub-embed-content .ap-preview audio{width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta span.ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}
diff --git a/build/blocks/reply/index.js b/build/blocks/reply/index.js
new file mode 100644
index 000000000..e9f5749b6
--- /dev/null
+++ b/build/blocks/reply/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var e={20:(e,t,r)=>{var n=r(609),o=Symbol.for("react.element"),a=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),l=n.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,i={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var n,c={},s=null,d=null;for(n in void 0!==r&&(s=""+r),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(d=t.ref),t)a.call(t,n)&&!i.hasOwnProperty(n)&&(c[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps)void 0===c[n]&&(c[n]=t[n]);return{$$typeof:o,type:e,key:s,ref:d,props:c,_owner:l.current}}},609:e=>{e.exports=window.React},848:(e,t,r)=>{e.exports=r(20)}},t={};function r(n){var o=t[n];if(void 0!==o)return o.exports;var a=t[n]={exports:{}};return e[n](a,a.exports,r),a.exports}r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);const n=window.wp.blocks,o=window.wp.primitives;var a=r(848);const l=(0,a.jsx)(o.SVG,{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:(0,a.jsx)(o.Path,{d:"M6.68822 10.625L6.24878 11.0649L5.5 11.8145L5.5 5.5L12.5 5.5V8L14 6.5V5C14 4.44772 13.5523 4 13 4H5C4.44772 4 4 4.44771 4 5V13.5247C4 13.8173 4.16123 14.086 4.41935 14.2237C4.72711 14.3878 5.10601 14.3313 5.35252 14.0845L7.31 12.125H8.375L9.875 10.625H7.31H6.68822ZM14.5605 10.4983L11.6701 13.75H16.9975C17.9963 13.75 18.7796 14.1104 19.3553 14.7048C19.9095 15.2771 20.2299 16.0224 20.4224 16.7443C20.7645 18.0276 20.7543 19.4618 20.7487 20.2544C20.7481 20.345 20.7475 20.4272 20.7475 20.4999L19.2475 20.5001C19.2475 20.4191 19.248 20.3319 19.2484 20.2394V20.2394C19.2526 19.4274 19.259 18.2035 18.973 17.1307C18.8156 16.5401 18.586 16.0666 18.2778 15.7483C17.9909 15.4521 17.5991 15.25 16.9975 15.25H11.8106L14.5303 17.9697L13.4696 19.0303L8.96956 14.5303L13.4394 9.50171L14.5605 10.4983Z"})});var i=r(609);const c=window.wp.blockEditor,s=window.wp.components,d=window.wp.i18n,u=window.wp.element,m=window.wp.compose,p=window.wp.apiFetch;var f=r.n(p);const h=window.wp.url,w=window.wp.data;function b({html:e}){const t=(0,u.useRef)(null),[r,n]=(0,u.useState)(300),o=(0,u.useRef)(300),a=(0,u.useCallback)((()=>{if(t.current)try{const e=t.current;let r=300;try{e.contentDocument&&e.contentDocument.body?r=e.contentDocument.body.scrollHeight:e.contentWindow&&e.contentWindow.document&&e.contentWindow.document.body&&(r=e.contentWindow.document.body.scrollHeight)}catch(e){console.log("Could not access iframe content document:",e)}r+=5,Math.abs(r-o.current)>5&&(o.current=r,n(r))}catch(e){console.error("Error adjusting iframe height:",e)}}),[]),l=(0,u.useCallback)((()=>{if(t.current)try{a()}catch(e){console.error("Error setting up iframe height adjustment:",e)}}),[a]);return(0,u.useEffect)((()=>{t.current&&t.current.addEventListener("load",l);const e=setInterval(a,1e3);return()=>{clearInterval(e),t.current&&t.current.removeEventListener("load",l)}}),[l,a]),(0,u.useEffect)((()=>{if(t.current){const e=setTimeout((()=>{a()}),100);return()=>clearTimeout(e)}}),[e,a]),{iframeRef:t,iframeHeight:r,adjustIframeHeight:a,handleIframeLoad:l}}const y={class:"className",frameborder:"frameBorder",allowfullscreen:"allowFullScreen",allowtransparency:"allowTransparency",marginheight:"marginHeight",marginwidth:"marginWidth"};function v({onClick:e}){return(0,i.createElement)("div",{className:"activitypub-embed-overlay",onClick:e,style:{position:"absolute",top:0,left:0,width:"100%",height:"100%",cursor:"pointer",zIndex:1}})}function _({html:e,onSelectBlock:t}){const r=(0,u.useRef)(),[n,o]=(0,u.useState)(282),[a,l]=(0,u.useState)(!1),c=(0,u.useCallback)((()=>{const t=(new window.DOMParser).parseFromString(e,"text/html").querySelector("iframe"),r={};return t?(Array.from(t.attributes).forEach((({name:e,value:t})=>{"style"!==e&&(r[y[e]||e]=t)})),r):r}),[e]),s=c();return(0,u.useEffect)((()=>{if(!r.current)return;const{ownerDocument:e}=r.current,{defaultView:t}=e;function n({data:{secret:e,message:t,value:r}={}}){"height"===t&&e===s["data-secret"]&&o(r)}return t.addEventListener("message",n),()=>{t.removeEventListener("message",n)}}),[s]),s.src?(0,i.createElement)("div",{className:"wp-block-embed__wrapper",style:{position:"relative"}},(0,i.createElement)("iframe",{ref:r,title:s.title||(0,d.__)("Embedded WordPress content","activitypub"),...s,height:n,style:{width:"100%",maxWidth:"100%"}}),!a&&(0,i.createElement)(v,{onClick:t})):(0,i.createElement)("div",{className:"wp-block-embed__wrapper",style:{position:"relative"}},(0,i.createElement)("div",{dangerouslySetInnerHTML:{__html:e}}),(0,i.createElement)(v,{onClick:t}))}function g({html:e,onClick:t,isSelected:r}){const{iframeRef:n,iframeHeight:o,adjustIframeHeight:a,handleIframeLoad:l}=b({html:e}),c=(0,u.useCallback)((()=>`\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t \n\t\t\t\t \n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t${e}\n\t\t\t\n\t\t\t\n\t\t`),[e]);return(0,i.createElement)("div",{className:"wp-block-embed__wrapper",style:{position:"relative"}},(0,i.createElement)("iframe",{ref:n,srcDoc:c(),sandbox:"allow-scripts allow-same-origin allow-popups allow-forms",style:{width:"100%",height:`${o}px`,border:"none",overflow:"hidden"},onLoad:l}),r&&(0,i.createElement)("div",{onClick:t,style:{position:"absolute",top:0,left:0,width:"100%",height:"100%",cursor:"pointer",zIndex:1,display:r?"block":"none"}}))}const E={default:(0,d.__)("Enter the URL of a post from the Fediverse (Mastodon, Pixelfed, etc.) that you want to reply to.","activitypub"),checking:()=>(0,i.createElement)(i.Fragment,null,(0,i.createElement)(s.Spinner,null)," "+(0,d.__)("Checking if this URL supports ActivityPub replies...","activitypub")),valid:(0,d.__)("The author will be notified of your response.","activitypub"),error:(0,d.__)("This URL probably won’t receive your reply. We’ll still try.","activitypub")},k={valid:(0,d.__)("This post can be embedded with your reply.","activitypub"),invalid:(0,d.__)("This post cannot be embedded.","activitypub")};(0,n.registerBlockType)("activitypub/reply",{edit:function({attributes:e,setAttributes:t,clientId:r,isSelected:n}){const{url:o}=e,{namespace:a}=window._activityPubOptions||{},[l,p]=(0,u.useState)(E.default),[y,v]=(0,u.useState)(!1),[C,L]=(0,u.useState)(!1),[S,P]=(0,u.useState)(!1),[x,R]=(0,u.useState)(!0===e.embedPost||!o),[T,H]=(0,u.useState)(null),{iframeRef:I,iframeHeight:O,adjustIframeHeight:D,handleIframeLoad:j}=b({html:T}),{insertAfterBlock:B,removeBlock:N}=(0,w.useDispatch)("core/block-editor"),W=(0,c.useBlockProps)(),F=(0,u.useRef)(),M=((0,u.useRef)(),(0,u.useRef)(x)),U=()=>{setTimeout((()=>F.current?.focus()),50)};(0,u.useEffect)((()=>{M.current=x}),[x]);const A=(0,u.useCallback)((e=>{v(e),M.current&&e&&t({embedPost:!0})}),[t]),V=(e=!1)=>{P(e),v(!1),L(!1),H("")},$=(0,m.useDebounce)((async e=>{if(e)try{V(!0),p(E.checking());const t=await f()({path:(0,h.addQueryArgs)(`${a}/url/validate`,{url:e})});A(t.is_activitypub),L(t.is_real_oembed),H(t.html||""),p(E.valid)}catch(e){V(),p(E.error)}finally{P(!1)}else V()}),250);return(0,u.useEffect)((()=>{o&&$(o)}),[o]),(0,i.createElement)(i.Fragment,null,(0,i.createElement)(c.InspectorControls,null,(0,i.createElement)(s.PanelBody,{title:(0,d.__)("Settings","activitypub")},(0,i.createElement)(s.ToggleControl,{label:(0,d.__)("Embed Post","activitypub"),checked:e.embedPost,onChange:e=>{t({embedPost:e}),R(e)},disabled:!y,help:y?k.valid:k.invalid}))),(0,i.createElement)("div",{...W},n&&(0,i.createElement)(s.TextControl,{label:(0,d.__)("Your post is a reply to the following URL","activitypub"),value:o,onChange:e=>t({url:e}),help:l,onKeyDown:t=>{"Enter"===t.key&&B(r),!e.url&&["Backspace","Delete"].includes(t.key)&&N(r)},ref:F}),y&&e.embedPost&&T&&(0,i.createElement)("div",{className:"activitypub-embed-container"},C&&(Y=T)&&(Y.includes("wp-embedded-content")||Y.includes("wp-embed/")||Y.includes('class="wp-embed"'))?(0,i.createElement)(_,{html:T,onSelectBlock:U}):(0,i.createElement)(g,{html:T,onClick:U,isSelected:n})),o&&(!e.embedPost||!T)&&(0,i.createElement)("div",{className:"activitypub-reply-block-editor__preview",contentEditable:!1,onClick:U,style:{cursor:"pointer"}},(0,i.createElement)("a",{href:o,className:"u-in-reply-to",target:"_blank",rel:"noreferrer"},"↬"+o.replace(/^https?:\/\//,"")))));var Y},save:()=>null,icon:l})})();
\ No newline at end of file
diff --git a/build/editor-plugin/plugin.asset.php b/build/editor-plugin/plugin.asset.php
deleted file mode 100644
index 35f04faca..000000000
--- a/build/editor-plugin/plugin.asset.php
+++ /dev/null
@@ -1 +0,0 @@
- array('react', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives', 'wp-url'), 'version' => 'c4ec9c3a1f0d32bd9118');
diff --git a/build/editor-plugin/plugin.js b/build/editor-plugin/plugin.js
deleted file mode 100644
index a282eff0b..000000000
--- a/build/editor-plugin/plugin.js
+++ /dev/null
@@ -1 +0,0 @@
-(()=>{"use strict";var e={20:(e,t,i)=>{var n=i(609),a=Symbol.for("react.element"),o=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),l=n.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,r={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,i){var n,c={},s=null,u=null;for(n in void 0!==i&&(s=""+i),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(u=t.ref),t)o.call(t,n)&&!r.hasOwnProperty(n)&&(c[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps)void 0===c[n]&&(c[n]=t[n]);return{$$typeof:a,type:e,key:s,ref:u,props:c,_owner:l.current}}},609:e=>{e.exports=window.React},848:(e,t,i)=>{e.exports=i(20)}},t={};function i(n){var a=t[n];if(void 0!==a)return a.exports;var o=t[n]={exports:{}};return e[n](o,o.exports,i),o.exports}var n=i(609);const a=window.wp.editor,o=window.wp.plugins,l=window.wp.components,r=window.wp.element,c=(0,r.forwardRef)((function({icon:e,size:t=24,...i},n){return(0,r.cloneElement)(e,{width:t,height:t,...i,ref:n})})),s=window.wp.primitives;var u=i(848);const p=(0,u.jsx)(s.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,u.jsx)(s.Path,{d:"M12 3.3c-4.8 0-8.8 3.9-8.8 8.8 0 4.8 3.9 8.8 8.8 8.8 4.8 0 8.8-3.9 8.8-8.8s-4-8.8-8.8-8.8zm6.5 5.5h-2.6C15.4 7.3 14.8 6 14 5c2 .6 3.6 2 4.5 3.8zm.7 3.2c0 .6-.1 1.2-.2 1.8h-2.9c.1-.6.1-1.2.1-1.8s-.1-1.2-.1-1.8H19c.2.6.2 1.2.2 1.8zM12 18.7c-1-.7-1.8-1.9-2.3-3.5h4.6c-.5 1.6-1.3 2.9-2.3 3.5zm-2.6-4.9c-.1-.6-.1-1.1-.1-1.8 0-.6.1-1.2.1-1.8h5.2c.1.6.1 1.1.1 1.8s-.1 1.2-.1 1.8H9.4zM4.8 12c0-.6.1-1.2.2-1.8h2.9c-.1.6-.1 1.2-.1 1.8 0 .6.1 1.2.1 1.8H5c-.2-.6-.2-1.2-.2-1.8zM12 5.3c1 .7 1.8 1.9 2.3 3.5H9.7c.5-1.6 1.3-2.9 2.3-3.5zM10 5c-.8 1-1.4 2.3-1.8 3.8H5.5C6.4 7 8 5.6 10 5zM5.5 15.3h2.6c.4 1.5 1 2.8 1.8 3.7-1.8-.6-3.5-2-4.4-3.7zM14 19c.8-1 1.4-2.2 1.8-3.7h2.6C17.6 17 16 18.4 14 19z"})}),v=(0,u.jsx)(s.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,u.jsx)(s.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),w=(0,u.jsx)(s.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,u.jsx)(s.Path,{d:"M19.5 4.5h-7V6h4.44l-5.97 5.97 1.06 1.06L18 7.06v4.44h1.5v-7Zm-13 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3H17v3a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h3V5.5h-3Z"})}),d=window.wp.data,_=window.wp.coreData,m=window.wp.url,h=window.wp.i18n,b=(0,n.createElement)(s.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24"},(0,n.createElement)(s.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5A6.5 6.5 0 0 1 6.93 7.931l9.139 9.138A6.473 6.473 0 0 1 12 18.5Zm5.123-2.498a6.5 6.5 0 0 0-9.124-9.124l9.124 9.124ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Z"}));(0,o.registerPlugin)("activitypub-editor-plugin",{render:()=>{var e,t;const i=(0,d.useSelect)((e=>e("core/editor").getCurrentPostType()),[]),[o,r]=(0,_.useEntityProp)("postType",i,"meta"),s={verticalAlign:"middle",gap:"4px",justifyContent:"start",display:"inline-flex",alignItems:"center"},u=(e,t,i)=>(0,n.createElement)(l.Tooltip,{text:i},(0,n.createElement)(l.__experimentalText,{style:s},(0,n.createElement)(c,{icon:e}),t));return"wp_block"===i?null:(0,n.createElement)(a.PluginDocumentSettingPanel,{name:"activitypub",title:(0,h.__)("Fediverse ⁂","activitypub")},(0,n.createElement)(l.TextControl,{label:(0,h.__)("Content Warning","activitypub"),value:o?.activitypub_content_warning,onChange:e=>{r({...o,activitypub_content_warning:e})},placeholder:(0,h.__)("Optional content warning","activitypub"),help:(0,h.__)("Content warnings do not change the content on your site, only in the fediverse.","activitypub")}),(0,n.createElement)(l.RangeControl,{label:(0,h.__)("Maximum Image Attachments","activitypub"),value:null!==(e=null!==(t=o?.activitypub_max_image_attachments)&&void 0!==t?t:window._activityPubOptions?.maxImageAttachments)&&void 0!==e?e:4,onChange:e=>{r({...o,activitypub_max_image_attachments:e})},min:0,max:10,help:(0,h.__)("Maximum number of image attachments to include when sharing to the fediverse.","activitypub")}),(0,n.createElement)(l.RadioControl,{label:(0,h.__)("Visibility","activitypub"),help:(0,h.__)("This adjusts the visibility of a post in the fediverse, but note that it won't affect how the post appears on the blog.","activitypub"),selected:o?.activitypub_content_visibility||"public",options:[{label:u(p,(0,h.__)("Public","activitypub"),(0,h.__)("Post will be visible to everyone and appear in public timelines.","activitypub")),value:"public"},{label:u(v,(0,h.__)("Quiet public","activitypub"),(0,h.__)("Post will be visible to everyone but will not appear in public timelines.","activitypub")),value:"quiet_public"},{label:u(b,(0,h.__)("Do not federate","activitypub"),(0,h.__)("Post will not be shared to the Fediverse.","activitypub")),value:"local"}],onChange:e=>{r({...o,activitypub_content_visibility:e})},className:"activitypub-visibility"}))}}),(0,o.registerPlugin)("activitypub-editor-preview",{render:()=>{const e=(0,d.useSelect)((e=>e("core/editor").getCurrentPost().status));return(0,n.createElement)(n.Fragment,null,a.PluginPreviewMenuItem?(0,n.createElement)(a.PluginPreviewMenuItem,{onClick:()=>function(){const e=(0,d.select)("core/editor").getEditedPostPreviewLink(),t=(0,m.addQueryArgs)(e,{activitypub:"true"});window.open(t,"_blank")}(),icon:w,disabled:"auto-draft"===e},(0,h.__)("Fediverse preview ⁂","activitypub")):null)}})})();
\ No newline at end of file
diff --git a/build/embed/embed-rtl.css b/build/embed/embed-rtl.css
new file mode 100644
index 000000000..703b8b72f
--- /dev/null
+++ b/build/embed/embed-rtl.css
@@ -0,0 +1 @@
+.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6}.activitypub-embed-content .ap-preview img{display:block;height:auto}.activitypub-embed-content .ap-preview{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0 0;min-height:64px;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.activitypub-embed-content .ap-preview.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}.activitypub-embed-content .ap-preview.layout-3>img:first-child{grid-row:span 2}.activitypub-embed-content .ap-preview img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview audio,.activitypub-embed-content .ap-preview video{display:block;grid-column:1/span 2;max-width:100%}.activitypub-embed-content .ap-preview audio{width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta span.ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}
diff --git a/build/embed/embed.css b/build/embed/embed.css
new file mode 100644
index 000000000..703b8b72f
--- /dev/null
+++ b/build/embed/embed.css
@@ -0,0 +1 @@
+.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6}.activitypub-embed-content .ap-preview img{display:block;height:auto}.activitypub-embed-content .ap-preview{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0 0;min-height:64px;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.activitypub-embed-content .ap-preview.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}.activitypub-embed-content .ap-preview.layout-3>img:first-child{grid-row:span 2}.activitypub-embed-content .ap-preview img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}.activitypub-embed-content .ap-preview audio,.activitypub-embed-content .ap-preview video{display:block;grid-column:1/span 2;max-width:100%}.activitypub-embed-content .ap-preview audio{width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta span.ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}
diff --git a/build/follow-me/index.asset.php b/build/follow-me/index.asset.php
deleted file mode 100644
index 59e5f6f9f..000000000
--- a/build/follow-me/index.asset.php
+++ /dev/null
@@ -1 +0,0 @@
- array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => 'd69f8905fbe5ea6410fa');
diff --git a/build/follow-me/index.js b/build/follow-me/index.js
deleted file mode 100644
index a0131f1b8..000000000
--- a/build/follow-me/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-(()=>{"use strict";var e,t={20:(e,t,o)=>{var r=o(609),l=Symbol.for("react.element"),n=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),a=r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,i={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,o){var r,c={},u=null,s=null;for(r in void 0!==o&&(u=""+o),void 0!==t.key&&(u=""+t.key),void 0!==t.ref&&(s=t.ref),t)n.call(t,r)&&!i.hasOwnProperty(r)&&(c[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps)void 0===c[r]&&(c[r]=t[r]);return{$$typeof:l,type:e,key:u,ref:s,props:c,_owner:a.current}}},609:e=>{e.exports=window.React},848:(e,t,o)=>{e.exports=o(20)},919:(e,t,o)=>{const r=window.wp.blocks,l=window.wp.primitives;var n=o(848);const a=(0,n.jsx)(l.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,n.jsx)(l.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})});var i=o(609);const c=window.wp.blockEditor,u=window.wp.i18n,s=window.wp.data,p=window.wp.coreData,d=window.wp.components,m=window.wp.element;function v(){return window._activityPubOptions||{}}const b=window.wp.apiFetch;var f=o.n(b);function y(e){return`var(--wp--preset--color--${e})`}function _(e){if("string"!=typeof e)return null;if(e.match(/^#/))return e.substring(0,7);const[,,t]=e.split("|");return y(t)}function h(e,t,o=null,r=""){return o?`${e}${r} { ${t}: ${o}; }\n`:""}function w(e,t,o,r){return h(e,"background-color",t)+h(e,"color",o)+h(e,"background-color",r,":hover")+h(e,"background-color",r,":focus")}function g({selector:e,style:t,backgroundColor:o}){const r=function(e,t,o){const r=`${e} .components-button`,l=("string"==typeof(n=o)?y(n):n?.color?.background||null)||t?.color?.background;var n;return w(r,_(t?.elements?.link?.color?.text),l,_(t?.elements?.link?.[":hover"]?.color?.text))}(e,t,o);return(0,i.createElement)("style",null,r)}const E=(0,n.jsx)(l.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,n.jsx)(l.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5 4.5h11a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM3 5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm17 3v10.75c0 .69-.56 1.25-1.25 1.25H6v1.5h12.75a2.75 2.75 0 0 0 2.75-2.75V8H20Z"})}),x=(0,n.jsx)(l.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,n.jsx)(l.Path,{d:"M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z"})}),S=(0,m.forwardRef)((function({icon:e,size:t=24,...o},r){return(0,m.cloneElement)(e,{width:t,height:t,...o,ref:r})})),k=window.wp.compose,C="fediverse-remote-user";function O(e){try{return new URL(e),!0}catch(e){return!1}}function T({actionText:e,copyDescription:t,handle:o,resourceUrl:r,myProfile:l="",rememberProfile:n=!1}){const c=(0,u.__)("Loading...","activitypub"),s=(0,u.__)("Opening...","activitypub"),p=(0,u.__)("Error","activitypub"),v=(0,u.__)("Invalid","activitypub"),b=l||(0,u.__)("My Profile","activitypub"),[y,_]=(0,m.useState)(e),[h,w]=(0,m.useState)(E),g=(0,k.useCopyToClipboard)(o,(()=>{w(x),setTimeout((()=>w(E)),1e3)})),[T,N]=(0,m.useState)(""),[I,R]=(0,m.useState)(!0),{setRemoteUser:U}=function(){const[e,t]=(0,m.useState)(function(){const e=localStorage.getItem(C);return e?JSON.parse(e):{}}()),o=(0,m.useCallback)((e=>{!function(e){localStorage.setItem(C,JSON.stringify(e))}(e),t(e)}),[]),r=(0,m.useCallback)((()=>{localStorage.removeItem(C),t({})}),[]);return{template:e?.template||!1,profileURL:e?.profileURL||!1,setRemoteUser:o,deleteRemoteUser:r}}(),z=(0,m.useCallback)((()=>{let t;if(!O(T)&&!function(e){const t=e.replace(/^@/,"").split("@");return 2===t.length&&O(`https://${t[1]}`)}(T))return _(v),t=setTimeout((()=>_(e)),2e3),()=>clearTimeout(t);const o=r+T;_(c),f()({path:o}).then((({url:t,template:o})=>{I&&U({profileURL:T,template:o}),_(s),setTimeout((()=>{window.open(t,"_blank"),_(e)}),200)})).catch((()=>{_(p),setTimeout((()=>_(e)),2e3)}))}),[T]);return(0,i.createElement)("div",{className:"activitypub__dialog",role:"dialog","aria-labelledby":"dialog-title"},(0,i.createElement)("div",{className:"activitypub-dialog__section"},(0,i.createElement)("h4",{id:"dialog-title"},b),(0,i.createElement)("div",{className:"activitypub-dialog__description",id:"copy-description"},t),(0,i.createElement)("div",{className:"activitypub-dialog__button-group"},(0,i.createElement)("label",{htmlFor:"profile-handle",className:"screen-reader-text"},t),(0,i.createElement)("input",{type:"text",id:"profile-handle",value:o,readOnly:!0}),(0,i.createElement)(d.Button,{ref:g,"aria-label":(0,u.__)("Copy handle to clipboard","activitypub")},(0,i.createElement)(S,{icon:h}),(0,u.__)("Copy","activitypub")))),(0,i.createElement)("div",{className:"activitypub-dialog__section"},(0,i.createElement)("h4",{id:"remote-profile-title"},(0,u.__)("Your Profile","activitypub")),(0,i.createElement)("div",{className:"activitypub-dialog__description",id:"remote-profile-description"},(0,m.createInterpolateElement)((0,u.__)("Or, if you know your own profile, we can start things that way! (eg @yourusername@example.com
)","activitypub"),{code:(0,i.createElement)("code",null)})),(0,i.createElement)("div",{className:"activitypub-dialog__button-group"},(0,i.createElement)("label",{htmlFor:"remote-profile",className:"screen-reader-text"},(0,u.__)("Enter your ActivityPub profile","activitypub")),(0,i.createElement)("input",{type:"text",id:"remote-profile",value:T,onKeyDown:e=>{"Enter"===e?.code&&z()},onChange:e=>N(e.target.value),"aria-invalid":y===v}),(0,i.createElement)(d.Button,{onClick:z,"aria-label":(0,u.__)("Submit profile","activitypub")},(0,i.createElement)(S,{icon:a}),y)),n&&(0,i.createElement)("div",{className:"activitypub-dialog__remember"},(0,i.createElement)(d.CheckboxControl,{checked:I,label:(0,u.__)("Remember me for easier comments","activitypub"),onChange:()=>{R(!I)}}))))}const N={avatar:"",webfinger:"@well@hello.dolly",name:(0,u.__)("Hello Dolly Fan Account","activitypub"),url:"#"};function I(e){if(!e)return N;const t={...N,...e};return t.avatar=t?.icon?.url,t}function R({profile:e,popupStyles:t,userId:o,buttonText:r,buttonOnly:l,buttonSize:n}){const{webfinger:a,avatar:c,name:u}=e,s=a.startsWith("@")?a:`@${a}`;return l?(0,i.createElement)("div",{className:"activitypub-profile"},(0,i.createElement)(U,{profile:e,popupStyles:t,userId:o,buttonText:r,buttonSize:n})):(0,i.createElement)("div",{className:"activitypub-profile"},(0,i.createElement)("img",{className:"activitypub-profile__avatar",src:c,alt:u}),(0,i.createElement)("div",{className:"activitypub-profile__content"},(0,i.createElement)("div",{className:"activitypub-profile__name"},u),(0,i.createElement)("div",{className:"activitypub-profile__handle",title:s},s)),(0,i.createElement)(U,{profile:e,popupStyles:t,userId:o,buttonText:r,buttonSize:n}))}function U({profile:e,popupStyles:t,userId:o,buttonText:r,buttonSize:l}){const[n,a]=(0,m.useState)(!1),c=(0,u.sprintf)(/* translators: %s: profile name */ /* translators: %s: profile name */
-(0,u.__)("Follow %s","activitypub"),e?.name);return(0,i.createElement)(i.Fragment,null,(0,i.createElement)(d.Button,{className:"activitypub-profile__follow",onClick:()=>a(!0),"aria-haspopup":"dialog","aria-expanded":n,"aria-label":(0,u.__)("Follow me on the Fediverse","activitypub"),size:l},r),n&&(0,i.createElement)(d.Modal,{className:"activitypub-profile__confirm activitypub__modal",onRequestClose:()=>a(!1),title:c,"aria-label":c,role:"dialog"},(0,i.createElement)(z,{profile:e,userId:o}),(0,i.createElement)("style",null,t)))}function z({profile:e,userId:t}){const{namespace:o}=v(),{webfinger:r}=e,l=(0,u.__)("Follow","activitypub"),n=`/${o}/actors/${t}/remote-follow?resource=`,a=(0,u.__)("Copy and paste my profile into the search field of your favorite fediverse app or server.","activitypub"),c=r.startsWith("@")?r:`@${r}`;return(0,i.createElement)(T,{actionText:l,copyDescription:a,handle:c,resourceUrl:n})}function $({selectedUser:e,style:t,backgroundColor:o,id:r,useId:l=!1,profileData:n=!1,buttonOnly:a=!1,buttonText:c=(0,u.__)("Follow","activitypub"),buttonSize:s="default"}){const[p,d]=(0,m.useState)(I()),b="site"===e?0:e,y=function(e){return w(".apfmd__button-group .components-button",_(e?.elements?.link?.color?.text)||"#111","#fff",_(e?.elements?.link?.[":hover"]?.color?.text)||"#333")}(t),h=l?{id:r}:{};return(0,m.useEffect)((()=>{n?d(I(n)):function(e){const{namespace:t}=v(),o={headers:{Accept:"application/activity+json"},path:`/${t}/actors/${e}`};return f()(o)}(b).then((e=>{d(I(e))}))}),[b,n]),(0,i.createElement)("div",{...h,className:"activitypub-follow-me-block-wrapper"},(0,i.createElement)(g,{selector:`#${r}`,style:t,backgroundColor:o}),(0,i.createElement)(R,{profile:p,userId:b,popupStyles:y,buttonText:c,buttonOnly:a,buttonSize:s}))}function P({name:e}){const{enabled:t}=v(),o=t?.site?"":(0,u.__)("It will be empty in other non-author contexts.","activitypub"),r=(0,u.sprintf)(/* translators: %1$s: block name, %2$s: extra information for non-author context */ /* translators: %1$s: block name, %2$s: extra information for non-author context */
-(0,u.__)("This %1$s block will adapt to the page it is on, displaying the user profile associated with a post author (in a loop) or a user archive. %2$s","activitypub"),e,o).trim();return(0,i.createElement)(d.Card,null,(0,i.createElement)(d.CardBody,null,(0,m.createInterpolateElement)(r,{strong:(0,i.createElement)("strong",null)})))}(0,r.registerBlockType)("activitypub/follow-me",{edit:function({attributes:e,setAttributes:t,context:{postType:o,postId:r}}){const l=(0,c.useBlockProps)({className:"activitypub-follow-me-block-wrapper"}),n=function({withInherit:e=!1}){const{enabled:t}=v(),o=t?.users?(0,s.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,m.useMemo)((()=>{if(!o)return[];const r=[];return t?.site&&r.push({label:(0,u.__)("Site","activitypub"),value:"site"}),e&&t?.users&&r.push({label:(0,u.__)("Dynamic User","activitypub"),value:"inherit"}),o.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),r)}),[o])}({withInherit:!0}),{selectedUser:a,buttonOnly:b,buttonText:f,buttonSize:y}=e,_="inherit"===a,h=(0,s.useSelect)((e=>{const{getEditedEntityRecord:t}=e(p.store),l=t("postType",o,r)?.author;return null!=l?l:null}),[o,r]);return(0,m.useEffect)((()=>{n.length&&(n.find((({value:e})=>e===a))||t({selectedUser:n[0].value}))}),[a,n]),(0,i.createElement)("div",{...l},(0,i.createElement)(c.InspectorControls,{key:"activitypub-follow-me"},(0,i.createElement)(d.PanelBody,{title:(0,u.__)("Follow Me Options","activitypub")},n.length>1&&(0,i.createElement)(d.SelectControl,{label:(0,u.__)("Select User","activitypub"),value:e.selectedUser,options:n,onChange:e=>t({selectedUser:e})}),(0,i.createElement)(d.ToggleControl,{label:(0,u.__)("Button Only Mode","activitypub"),checked:b,onChange:e=>t({buttonOnly:e}),help:(0,u.__)("Only show the follow button without profile information","activitypub")}),(0,i.createElement)(d.TextControl,{label:(0,u.__)("Button Text","activitypub"),value:f,onChange:e=>t({buttonText:e})}),(0,i.createElement)(d.SelectControl,{label:(0,u.__)("Button Size","activitypub"),value:y,options:[{label:(0,u.__)("Default","activitypub"),value:"default"},{label:(0,u.__)("Compact","activitypub"),value:"compact"},{label:(0,u.__)("Small","activitypub"),value:"small"}],onChange:e=>t({buttonSize:e}),help:(0,u.__)("Choose the size of the follow button","activitypub")}))),_?h?(0,i.createElement)($,{...e,id:l.id,selectedUser:h}):(0,i.createElement)(P,{name:(0,u.__)("Follow Me","activitypub")}):(0,i.createElement)($,{...e,id:l.id}))},save:()=>null,icon:a})}},o={};function r(e){var l=o[e];if(void 0!==l)return l.exports;var n=o[e]={exports:{}};return t[e](n,n.exports,r),n.exports}r.m=t,e=[],r.O=(t,o,l,n)=>{if(!o){var a=1/0;for(s=0;s=n)&&Object.keys(r.O).every((e=>r.O[e](o[c])))?o.splice(c--,1):(i=!1,n0&&e[s-1][2]>n;s--)e[s]=e[s-1];e[s]=[o,l,n]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={338:0,301:0};r.O.j=t=>0===e[t];var t=(t,o)=>{var l,n,[a,i,c]=o,u=0;if(a.some((t=>0!==e[t]))){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)var s=c(r)}for(t&&t(o);u r(919)));l=r.O(l)})();
\ No newline at end of file
diff --git a/build/follow-me/view.asset.php b/build/follow-me/view.asset.php
deleted file mode 100644
index b2a00fcd5..000000000
--- a/build/follow-me/view.asset.php
+++ /dev/null
@@ -1 +0,0 @@
- array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '4e430690e282cfe013db');
diff --git a/build/follow-me/view.js b/build/follow-me/view.js
deleted file mode 100644
index 7d4de0582..000000000
--- a/build/follow-me/view.js
+++ /dev/null
@@ -1,2 +0,0 @@
-(()=>{"use strict";var e,t={5:(e,t,r)=>{var o=r(609);const a=window.wp.element,n=window.wp.domReady;var i=r.n(n);const l=window.wp.apiFetch;var c=r.n(l);const u=window.wp.components,s=window.wp.i18n;function p(e){return`var(--wp--preset--color--${e})`}function m(e){if("string"!=typeof e)return null;if(e.match(/^#/))return e.substring(0,7);const[,,t]=e.split("|");return p(t)}function d(e,t,r=null,o=""){return r?`${e}${o} { ${t}: ${r}; }\n`:""}function v(e,t,r,o){return d(e,"background-color",t)+d(e,"color",r)+d(e,"background-color",o,":hover")+d(e,"background-color",o,":focus")}function f({selector:e,style:t,backgroundColor:r}){const a=function(e,t,r){const o=`${e} .components-button`,a=("string"==typeof(n=r)?p(n):n?.color?.background||null)||t?.color?.background;var n;return v(o,m(t?.elements?.link?.color?.text),a,m(t?.elements?.link?.[":hover"]?.color?.text))}(e,t,r);return(0,o.createElement)("style",null,a)}const b=window.wp.primitives;var y=r(848);const _=(0,y.jsx)(b.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,y.jsx)(b.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5 4.5h11a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM3 5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm17 3v10.75c0 .69-.56 1.25-1.25 1.25H6v1.5h12.75a2.75 2.75 0 0 0 2.75-2.75V8H20Z"})}),w=(0,y.jsx)(b.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,y.jsx)(b.Path,{d:"M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z"})}),h=(0,a.forwardRef)((function({icon:e,size:t=24,...r},o){return(0,a.cloneElement)(e,{width:t,height:t,...r,ref:o})})),g=(0,y.jsx)(b.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,y.jsx)(b.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})}),E=window.wp.compose,x="fediverse-remote-user";function S(e){try{return new URL(e),!0}catch(e){return!1}}function k({actionText:e,copyDescription:t,handle:r,resourceUrl:n,myProfile:i="",rememberProfile:l=!1}){const p=(0,s.__)("Loading...","activitypub"),m=(0,s.__)("Opening...","activitypub"),d=(0,s.__)("Error","activitypub"),v=(0,s.__)("Invalid","activitypub"),f=i||(0,s.__)("My Profile","activitypub"),[b,y]=(0,a.useState)(e),[k,O]=(0,a.useState)(_),N=(0,E.useCopyToClipboard)(r,(()=>{O(w),setTimeout((()=>O(_)),1e3)})),[C,R]=(0,a.useState)(""),[T,I]=(0,a.useState)(!0),{setRemoteUser:$}=function(){const[e,t]=(0,a.useState)(function(){const e=localStorage.getItem(x);return e?JSON.parse(e):{}}()),r=(0,a.useCallback)((e=>{!function(e){localStorage.setItem(x,JSON.stringify(e))}(e),t(e)}),[]),o=(0,a.useCallback)((()=>{localStorage.removeItem(x),t({})}),[]);return{template:e?.template||!1,profileURL:e?.profileURL||!1,setRemoteUser:r,deleteRemoteUser:o}}(),z=(0,a.useCallback)((()=>{let t;if(!S(C)&&!function(e){const t=e.replace(/^@/,"").split("@");return 2===t.length&&S(`https://${t[1]}`)}(C))return y(v),t=setTimeout((()=>y(e)),2e3),()=>clearTimeout(t);const r=n+C;y(p),c()({path:r}).then((({url:t,template:r})=>{T&&$({profileURL:C,template:r}),y(m),setTimeout((()=>{window.open(t,"_blank"),y(e)}),200)})).catch((()=>{y(d),setTimeout((()=>y(e)),2e3)}))}),[C]);return(0,o.createElement)("div",{className:"activitypub__dialog",role:"dialog","aria-labelledby":"dialog-title"},(0,o.createElement)("div",{className:"activitypub-dialog__section"},(0,o.createElement)("h4",{id:"dialog-title"},f),(0,o.createElement)("div",{className:"activitypub-dialog__description",id:"copy-description"},t),(0,o.createElement)("div",{className:"activitypub-dialog__button-group"},(0,o.createElement)("label",{htmlFor:"profile-handle",className:"screen-reader-text"},t),(0,o.createElement)("input",{type:"text",id:"profile-handle",value:r,readOnly:!0}),(0,o.createElement)(u.Button,{ref:N,"aria-label":(0,s.__)("Copy handle to clipboard","activitypub")},(0,o.createElement)(h,{icon:k}),(0,s.__)("Copy","activitypub")))),(0,o.createElement)("div",{className:"activitypub-dialog__section"},(0,o.createElement)("h4",{id:"remote-profile-title"},(0,s.__)("Your Profile","activitypub")),(0,o.createElement)("div",{className:"activitypub-dialog__description",id:"remote-profile-description"},(0,a.createInterpolateElement)((0,s.__)("Or, if you know your own profile, we can start things that way! (eg @yourusername@example.com
)","activitypub"),{code:(0,o.createElement)("code",null)})),(0,o.createElement)("div",{className:"activitypub-dialog__button-group"},(0,o.createElement)("label",{htmlFor:"remote-profile",className:"screen-reader-text"},(0,s.__)("Enter your ActivityPub profile","activitypub")),(0,o.createElement)("input",{type:"text",id:"remote-profile",value:C,onKeyDown:e=>{"Enter"===e?.code&&z()},onChange:e=>R(e.target.value),"aria-invalid":b===v}),(0,o.createElement)(u.Button,{onClick:z,"aria-label":(0,s.__)("Submit profile","activitypub")},(0,o.createElement)(h,{icon:g}),b)),l&&(0,o.createElement)("div",{className:"activitypub-dialog__remember"},(0,o.createElement)(u.CheckboxControl,{checked:T,label:(0,s.__)("Remember me for easier comments","activitypub"),onChange:()=>{I(!T)}}))))}function O(){return window._activityPubOptions||{}}const N={avatar:"",webfinger:"@well@hello.dolly",name:(0,s.__)("Hello Dolly Fan Account","activitypub"),url:"#"};function C(e){if(!e)return N;const t={...N,...e};return t.avatar=t?.icon?.url,t}function R({profile:e,popupStyles:t,userId:r,buttonText:a,buttonOnly:n,buttonSize:i}){const{webfinger:l,avatar:c,name:u}=e,s=l.startsWith("@")?l:`@${l}`;return n?(0,o.createElement)("div",{className:"activitypub-profile"},(0,o.createElement)(T,{profile:e,popupStyles:t,userId:r,buttonText:a,buttonSize:i})):(0,o.createElement)("div",{className:"activitypub-profile"},(0,o.createElement)("img",{className:"activitypub-profile__avatar",src:c,alt:u}),(0,o.createElement)("div",{className:"activitypub-profile__content"},(0,o.createElement)("div",{className:"activitypub-profile__name"},u),(0,o.createElement)("div",{className:"activitypub-profile__handle",title:s},s)),(0,o.createElement)(T,{profile:e,popupStyles:t,userId:r,buttonText:a,buttonSize:i}))}function T({profile:e,popupStyles:t,userId:r,buttonText:n,buttonSize:i}){const[l,c]=(0,a.useState)(!1),p=(0,s.sprintf)(/* translators: %s: profile name */ /* translators: %s: profile name */
-(0,s.__)("Follow %s","activitypub"),e?.name);return(0,o.createElement)(o.Fragment,null,(0,o.createElement)(u.Button,{className:"activitypub-profile__follow",onClick:()=>c(!0),"aria-haspopup":"dialog","aria-expanded":l,"aria-label":(0,s.__)("Follow me on the Fediverse","activitypub"),size:i},n),l&&(0,o.createElement)(u.Modal,{className:"activitypub-profile__confirm activitypub__modal",onRequestClose:()=>c(!1),title:p,"aria-label":p,role:"dialog"},(0,o.createElement)(I,{profile:e,userId:r}),(0,o.createElement)("style",null,t)))}function I({profile:e,userId:t}){const{namespace:r}=O(),{webfinger:a}=e,n=(0,s.__)("Follow","activitypub"),i=`/${r}/actors/${t}/remote-follow?resource=`,l=(0,s.__)("Copy and paste my profile into the search field of your favorite fediverse app or server.","activitypub"),c=a.startsWith("@")?a:`@${a}`;return(0,o.createElement)(k,{actionText:n,copyDescription:l,handle:c,resourceUrl:i})}function $({selectedUser:e,style:t,backgroundColor:r,id:n,useId:i=!1,profileData:l=!1,buttonOnly:u=!1,buttonText:p=(0,s.__)("Follow","activitypub"),buttonSize:d="default"}){const[b,y]=(0,a.useState)(C()),_="site"===e?0:e,w=function(e){return v(".apfmd__button-group .components-button",m(e?.elements?.link?.color?.text)||"#111","#fff",m(e?.elements?.link?.[":hover"]?.color?.text)||"#333")}(t),h=i?{id:n}:{};return(0,a.useEffect)((()=>{l?y(C(l)):function(e){const{namespace:t}=O(),r={headers:{Accept:"application/activity+json"},path:`/${t}/actors/${e}`};return c()(r)}(_).then((e=>{y(C(e))}))}),[_,l]),(0,o.createElement)("div",{...h,className:"activitypub-follow-me-block-wrapper"},(0,o.createElement)(f,{selector:`#${n}`,style:t,backgroundColor:r}),(0,o.createElement)(R,{profile:b,userId:_,popupStyles:w,buttonText:p,buttonOnly:u,buttonSize:d}))}let z=1;i()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-follow-me-block-wrapper"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,a.createRoot)(e).render((0,o.createElement)($,{...t,id:"activitypub-follow-me-block-"+z++,useId:!0}))}))}))},20:(e,t,r)=>{var o=r(609),a=Symbol.for("react.element"),n=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),i=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,l={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var o,c={},u=null,s=null;for(o in void 0!==r&&(u=""+r),void 0!==t.key&&(u=""+t.key),void 0!==t.ref&&(s=t.ref),t)n.call(t,o)&&!l.hasOwnProperty(o)&&(c[o]=t[o]);if(e&&e.defaultProps)for(o in t=e.defaultProps)void 0===c[o]&&(c[o]=t[o]);return{$$typeof:a,type:e,key:u,ref:s,props:c,_owner:i.current}}},609:e=>{e.exports=window.React},848:(e,t,r)=>{e.exports=r(20)}},r={};function o(e){var a=r[e];if(void 0!==a)return a.exports;var n=r[e]={exports:{}};return t[e](n,n.exports,o),n.exports}o.m=t,e=[],o.O=(t,r,a,n)=>{if(!r){var i=1/0;for(s=0;s=n)&&Object.keys(o.O).every((e=>o.O[e](r[c])))?r.splice(c--,1):(l=!1,n0&&e[s-1][2]>n;s--)e[s]=e[s-1];e[s]=[r,a,n]},o.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={41:0,301:0};o.O.j=t=>0===e[t];var t=(t,r)=>{var a,n,[i,l,c]=r,u=0;if(i.some((t=>0!==e[t]))){for(a in l)o.o(l,a)&&(o.m[a]=l[a]);if(c)var s=c(o)}for(t&&t(r);uo(5)));a=o.O(a)})();
\ No newline at end of file
diff --git a/build/followers/index.js b/build/followers/index.js
deleted file mode 100644
index f05ec0f8a..000000000
--- a/build/followers/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-(()=>{var e={20:(e,t,a)=>{"use strict";var r=a(609),n=Symbol.for("react.element"),l=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),o=r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,i={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,a){var r,c={},s=null,p=null;for(r in void 0!==a&&(s=""+a),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(p=t.ref),t)l.call(t,r)&&!i.hasOwnProperty(r)&&(c[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps)void 0===c[r]&&(c[r]=t[r]);return{$$typeof:n,type:e,key:s,ref:p,props:c,_owner:o.current}}},609:e=>{"use strict";e.exports=window.React},848:(e,t,a)=>{"use strict";e.exports=a(20)},942:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e="",t=0;t{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e=window.wp.blocks,t=window.wp.primitives;var r=a(848);const n=(0,r.jsx)(t.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,r.jsx)(t.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"})});var l=a(609);const o=window.wp.components,i=window.wp.element,c=window.wp.blockEditor,s=window.wp.data,p=window.wp.coreData,u=window.wp.i18n,m=window.wp.apiFetch;var v=a.n(m);const d=window.wp.url;var w=a(942),f=a.n(w);function b({active:e,children:t,page:a,pageClick:r,className:n}){const o=f()("wp-block activitypub-pager",n,{current:e});return(0,l.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&r(a)}},t)}function y({compact:e,nextLabel:t,page:a,pageClick:r,perPage:n,prevLabel:o,total:i,variant:c="outlined"}){const s=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(a,Math.ceil(i/n)),p=f()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${c}`,{"is-compact":e});return(0,l.createElement)("nav",{className:p},o&&(0,l.createElement)(b,{key:"prev",page:a-1,pageClick:r,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,l.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,l.createElement)(b,{key:e,page:e,pageClick:r,active:e===a,className:"page-numbers"},e)))),t&&(0,l.createElement)(b,{key:"next",page:a+1,pageClick:r,active:a===Math.ceil(i/n),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}function g(){return window._activityPubOptions||{}}function h({selectedUser:e,per_page:t,order:a,title:r,page:n,setPage:o,className:c="",followLinks:s=!0,followerData:p=!1}){const m="site"===e?0:e,[w,f]=(0,l.useState)([]),[b,h]=(0,l.useState)(0),[k,E]=(0,l.useState)(0),[x,S]=function(){const[e,t]=(0,l.useState)(1);return[e,t]}(),N=n||x,C=o||S,O=(0,i.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
-(0,u.__)("← Less","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),P=(0,i.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
-(0,u.__)("More → ","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),I=(e,a)=>{f(e),E(a),h(Math.ceil(a/t))};return(0,l.useEffect)((()=>{if(p&&1===N)return I(p.followers,p.total);const e=function(e,t,a,r){const{namespace:n}=g(),l=`/${n}/actors/${e}/followers`,o={per_page:t,order:a,page:r,context:"full"};return(0,d.addQueryArgs)(l,o)}(m,t,a,N);v()({path:e}).then((e=>I(e.orderedItems,e.totalItems))).catch((()=>{}))}),[m,t,a,N,p]),(0,l.createElement)("div",{className:"activitypub-follower-block "+c},(0,l.createElement)("h3",null,r),(0,l.createElement)("ul",null,w&&w.map((e=>(0,l.createElement)("li",{key:e.url},(0,l.createElement)(_,{...e,followLinks:s}))))),b>1&&(0,l.createElement)(y,{page:N,perPage:t,total:k,pageClick:C,nextLabel:P,prevLabel:O,compact:"is-style-compact"===c}))}function _({name:e,icon:t,url:a,preferredUsername:r,followLinks:n=!0}){const i=`@${r}`,c={};n||(c.onClick=e=>e.preventDefault());const{defaultAvatarUrl:s}=g(),p=t.url||s;return(0,l.createElement)(o.ExternalLink,{className:"activitypub-link",href:a,title:i,...c},(0,l.createElement)("img",{width:"40",height:"40",src:p,className:"avatar activitypub-avatar",alt:e,onError:e=>{e.target.src=s}}),(0,l.createElement)("span",{className:"activitypub-actor"},(0,l.createElement)("strong",{className:"activitypub-name"},e),(0,l.createElement)("span",{className:"sep"},"/"),(0,l.createElement)("span",{className:"activitypub-handle"},i)))}function k({name:e}){const{enabled:t}=g(),a=t?.site?"":(0,u.__)("It will be empty in other non-author contexts.","activitypub"),r=(0,u.sprintf)(/* translators: %1$s: block name, %2$s: extra information for non-author context */ /* translators: %1$s: block name, %2$s: extra information for non-author context */
-(0,u.__)("This %1$s block will adapt to the page it is on, displaying the user profile associated with a post author (in a loop) or a user archive. %2$s","activitypub"),e,a).trim();return(0,l.createElement)(o.Card,null,(0,l.createElement)(o.CardBody,null,(0,i.createInterpolateElement)(r,{strong:(0,l.createElement)("strong",null)})))}(0,e.registerBlockType)("activitypub/followers",{edit:function({attributes:e,setAttributes:t,context:{postType:a,postId:r}}){const{order:n,per_page:m,selectedUser:v,title:d}=e,w=(0,c.useBlockProps)(),[f,b]=(0,i.useState)(1),y=[{label:(0,u.__)("New to old","activitypub"),value:"desc"},{label:(0,u.__)("Old to new","activitypub"),value:"asc"}],_=function({withInherit:e=!1}){const{enabled:t}=g(),a=t?.users?(0,s.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,i.useMemo)((()=>{if(!a)return[];const r=[];return t?.site&&r.push({label:(0,u.__)("Site","activitypub"),value:"site"}),e&&t?.users&&r.push({label:(0,u.__)("Dynamic User","activitypub"),value:"inherit"}),a.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),r)}),[a])}({withInherit:!0}),E=e=>a=>{b(1),t({[e]:a})},x=(0,s.useSelect)((e=>{const{getEditedEntityRecord:t}=e(p.store),n=t("postType",a,r)?.author;return null!=n?n:null}),[a,r]);return(0,i.useEffect)((()=>{_.length&&(_.find((({value:e})=>e===v))||t({selectedUser:_[0].value}))}),[v,_]),(0,l.createElement)("div",{...w},(0,l.createElement)(c.InspectorControls,{key:"setting"},(0,l.createElement)(o.PanelBody,{title:(0,u.__)("Followers Options","activitypub")},(0,l.createElement)(o.TextControl,{label:(0,u.__)("Title","activitypub"),help:(0,u.__)("Title to display above the list of followers. Blank for none.","activitypub"),value:d,onChange:e=>t({title:e})}),_.length>1&&(0,l.createElement)(o.SelectControl,{label:(0,u.__)("Select User","activitypub"),value:v,options:_,onChange:E("selectedUser")}),(0,l.createElement)(o.SelectControl,{label:(0,u.__)("Sort","activitypub"),value:n,options:y,onChange:E("order")}),(0,l.createElement)(o.RangeControl,{label:(0,u.__)("Number of Followers","activitypub"),value:m,onChange:E("per_page"),min:1,max:10}))),"inherit"===v?x?(0,l.createElement)(h,{...e,page:f,setPage:b,followLinks:!1,selectedUser:x}):(0,l.createElement)(k,{name:(0,u.__)("Followers","activitypub")}):(0,l.createElement)(h,{...e,page:f,setPage:b,followLinks:!1}))},save:()=>null,icon:n})})()})();
\ No newline at end of file
diff --git a/build/followers/view.asset.php b/build/followers/view.asset.php
deleted file mode 100644
index 1f3eb4d7c..000000000
--- a/build/followers/view.asset.php
+++ /dev/null
@@ -1 +0,0 @@
- array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => 'e1915374b8c762ae4376');
diff --git a/build/followers/view.js b/build/followers/view.js
deleted file mode 100644
index 451e63ae0..000000000
--- a/build/followers/view.js
+++ /dev/null
@@ -1,3 +0,0 @@
-(()=>{var e,t={73:(e,t,a)=>{"use strict";const r=window.React,n=window.wp.apiFetch;var l=a.n(n);const o=window.wp.url,c=window.wp.element,i=window.wp.i18n;var s=a(942),p=a.n(s);function u({active:e,children:t,page:a,pageClick:n,className:l}){const o=p()("wp-block activitypub-pager",l,{current:e});return(0,r.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&n(a)}},t)}function m({compact:e,nextLabel:t,page:a,pageClick:n,perPage:l,prevLabel:o,total:c,variant:i="outlined"}){const s=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(a,Math.ceil(c/l)),m=p()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${i}`,{"is-compact":e});return(0,r.createElement)("nav",{className:m},o&&(0,r.createElement)(u,{key:"prev",page:a-1,pageClick:n,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,r.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,r.createElement)(u,{key:e,page:e,pageClick:n,active:e===a,className:"page-numbers"},e)))),t&&(0,r.createElement)(u,{key:"next",page:a+1,pageClick:n,active:a===Math.ceil(c/l),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}const f=window.wp.components;function v(){return window._activityPubOptions||{}}function b({selectedUser:e,per_page:t,order:a,title:n,page:s,setPage:p,className:u="",followLinks:f=!0,followerData:b=!1}){const w="site"===e?0:e,[g,y]=(0,r.useState)([]),[k,h]=(0,r.useState)(0),[E,N]=(0,r.useState)(0),[x,_]=function(){const[e,t]=(0,r.useState)(1);return[e,t]}(),O=s||x,S=p||_,C=(0,c.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
-(0,i.__)("← Less","activitypub"),{span:(0,r.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(0,c.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
-(0,i.__)("More → ","activitypub"),{span:(0,r.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),q=(e,a)=>{y(e),N(a),h(Math.ceil(a/t))};return(0,r.useEffect)((()=>{if(b&&1===O)return q(b.followers,b.total);const e=function(e,t,a,r){const{namespace:n}=v(),l=`/${n}/actors/${e}/followers`,c={per_page:t,order:a,page:r,context:"full"};return(0,o.addQueryArgs)(l,c)}(w,t,a,O);l()({path:e}).then((e=>q(e.orderedItems,e.totalItems))).catch((()=>{}))}),[w,t,a,O,b]),(0,r.createElement)("div",{className:"activitypub-follower-block "+u},(0,r.createElement)("h3",null,n),(0,r.createElement)("ul",null,g&&g.map((e=>(0,r.createElement)("li",{key:e.url},(0,r.createElement)(d,{...e,followLinks:f}))))),k>1&&(0,r.createElement)(m,{page:O,perPage:t,total:E,pageClick:S,nextLabel:L,prevLabel:C,compact:"is-style-compact"===u}))}function d({name:e,icon:t,url:a,preferredUsername:n,followLinks:l=!0}){const o=`@${n}`,c={};l||(c.onClick=e=>e.preventDefault());const{defaultAvatarUrl:i}=v(),s=t.url||i;return(0,r.createElement)(f.ExternalLink,{className:"activitypub-link",href:a,title:o,...c},(0,r.createElement)("img",{width:"40",height:"40",src:s,className:"avatar activitypub-avatar",alt:e,onError:e=>{e.target.src=i}}),(0,r.createElement)("span",{className:"activitypub-actor"},(0,r.createElement)("strong",{className:"activitypub-name"},e),(0,r.createElement)("span",{className:"sep"},"/"),(0,r.createElement)("span",{className:"activitypub-handle"},o)))}const w=window.wp.domReady;a.n(w)()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-follower-block"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,c.createRoot)(e).render((0,r.createElement)(b,{...t}))}))}))},942:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e="",t=0;t{if(!a){var o=1/0;for(p=0;p=l)&&Object.keys(r.O).every((e=>r.O[e](a[i])))?a.splice(i--,1):(c=!1,l0&&e[p-1][2]>l;p--)e[p]=e[p-1];e[p]=[a,n,l]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var a in t)r.o(t,a)&&!r.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={996:0,528:0};r.O.j=t=>0===e[t];var t=(t,a)=>{var n,l,[o,c,i]=a,s=0;if(o.some((t=>0!==e[t]))){for(n in c)r.o(c,n)&&(r.m[n]=c[n]);if(i)var p=i(r)}for(t&&t(a);sr(73)));n=r.O(n)})();
\ No newline at end of file
diff --git a/build/reactions/block.json b/build/reactions/block.json
deleted file mode 100644
index c35f01771..000000000
--- a/build/reactions/block.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "$schema": "https://schemas.wp.org/trunk/block.json",
- "name": "activitypub/reactions",
- "apiVersion": 2,
- "version": "1.0.0",
- "title": "Fediverse Reactions",
- "category": "widgets",
- "icon": "heart",
- "description": "Display Fediverse likes and reposts",
- "supports": {
- "html": false,
- "align": true,
- "layout": {
- "default": {
- "type": "constrained",
- "orientation": "vertical",
- "justifyContent": "center"
- }
- }
- },
- "attributes": {},
- "blockHooks": {
- "core/post-content": "after"
- },
- "textdomain": "activitypub",
- "editorScript": "file:./index.js",
- "style": [
- "file:./style-index.css",
- "wp-components"
- ],
- "viewScript": "file:./view.js"
-}
\ No newline at end of file
diff --git a/build/reactions/index.asset.php b/build/reactions/index.asset.php
deleted file mode 100644
index 2f8424559..000000000
--- a/build/reactions/index.asset.php
+++ /dev/null
@@ -1 +0,0 @@
- array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '13c017000d37a3025875');
diff --git a/build/reactions/index.js b/build/reactions/index.js
deleted file mode 100644
index 0b4f85ba5..000000000
--- a/build/reactions/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-(()=>{"use strict";var e,t={29:(e,t,n)=>{const r=window.wp.blocks,a=[{attributes:{title:{type:"string",default:"Fediverse reactions"}},supports:{html:!1,align:!0,layout:{default:{type:"constrained",orientation:"vertical",justifyContent:"center"}}},isEligible:e=>!!e.title,migrate(e){const{title:t,...n}=e;return[n,[(0,r.createBlock)("core/heading",{content:t,level:6})]]}}],l=window.React,o=window.wp.blockEditor,i=window.wp.element,s=window.wp.i18n,c=window.wp.components,u=window.wp.apiFetch;var m=n.n(u);function p(){return window._activityPubOptions||{}}const h=({reactions:e})=>{const{defaultAvatarUrl:t}=p(),[n,r]=(0,i.useState)(new Set),[a,o]=(0,i.useState)(new Map),s=(0,i.useRef)([]),c=()=>{s.current.forEach((e=>clearTimeout(e))),s.current=[]},u=(t,n)=>{c();const a=100,l=e.length;n&&o((e=>{const n=new Map(e);return n.set(t,"clockwise"),n}));const i=e=>{const i="right"===e,c=i?l-1:0,u=i?1:-1;for(let e=i?t:t-1;i?e<=c:e>=c;e+=u){const l=Math.abs(e-t),i=setTimeout((()=>{r((t=>{const r=new Set(t);return n?r.add(e):r.delete(e),r})),n&&e!==t&&o((t=>{const n=new Map(t),r=e-u,a=n.get(r);return n.set(e,"clockwise"===a?"counter":"clockwise"),n}))}),l*a);s.current.push(i)}};if(i("right"),i("left"),!n){const e=Math.max((l-t)*a,t*a),n=setTimeout((()=>{o(new Map)}),e+a);s.current.push(n)}};return(0,i.useEffect)((()=>()=>c()),[]),(0,l.createElement)("ul",{className:"reaction-avatars"},e.map(((e,r)=>{const o=a.get(r),i=["reaction-avatar",n.has(r)?"wave-active":"",o?`rotate-${o}`:""].filter(Boolean).join(" "),s=e.avatar||t;return(0,l.createElement)("li",{key:r},(0,l.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",onMouseEnter:()=>u(r,!0),onMouseLeave:()=>u(r,!1)},(0,l.createElement)("img",{src:s,alt:e.name,className:i,width:"32",height:"32",onError:e=>{e.target.src=t}})))})))},f=({reactions:e,type:t})=>(0,l.createElement)("ul",{className:"activitypub-reaction-list"},e.map(((e,t)=>(0,l.createElement)("li",{key:t},(0,l.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,l.createElement)("img",{src:e.avatar,alt:e.name,width:"32",height:"32"}),(0,l.createElement)("span",null,e.name)))))),d=({items:e,label:t})=>{const[n,r]=(0,i.useState)(!1),[a,o]=(0,i.useState)(null),[s,u]=(0,i.useState)(e.length),m=(0,i.useRef)(null);(0,i.useEffect)((()=>{if(!m.current)return;const t=()=>{const t=m.current;if(!t)return;const n=t.offsetWidth-(a?.offsetWidth||0)-12,r=Math.max(1,Math.floor((n-32)/22));u(Math.min(r,e.length))};t();const n=new ResizeObserver(t);return n.observe(m.current),()=>{n.disconnect()}}),[a,e.length]);const p=e.slice(0,s);return(0,l.createElement)("div",{className:"reaction-group",ref:m},(0,l.createElement)(h,{reactions:p}),(0,l.createElement)(c.Button,{ref:o,className:"reaction-label is-link",onClick:()=>r(!n),"aria-expanded":n},t),n&&a&&(0,l.createElement)(c.Popover,{anchor:a,onClose:()=>r(!1)},(0,l.createElement)(f,{reactions:e})))};function g({postId:e=null,reactions:t=null,title:n=null}){const{namespace:r}=p(),[a,o]=(0,i.useState)(t),[s,c]=(0,i.useState)(!t);return(0,i.useEffect)((()=>{if(t)return o(t),void c(!1);e?(c(!0),m()({path:`/${r}/posts/${e}/reactions`}).then((e=>{o(e),c(!1)})).catch((()=>c(!1)))):c(!1)}),[e,t]),s?null:a&&Object.values(a).some((e=>e.items?.length>0))?(0,l.createElement)(l.Fragment,null,n&&(0,l.createElement)("h6",null,n),Object.entries(a).map((([e,t])=>t.items?.length?(0,l.createElement)(d,{key:e,items:t.items,label:t.label}):null))):null}const v=e=>{const t=["#FF6B6B","#4ECDC4","#45B7D1","#96CEB4","#FFEEAD","#D4A5A5","#9B59B6","#3498DB","#E67E22"],n=(()=>{const e=["Bouncy","Cosmic","Dancing","Fluffy","Giggly","Hoppy","Jazzy","Magical","Nifty","Perky","Quirky","Sparkly","Twirly","Wiggly","Zippy"],t=["Badger","Capybara","Dolphin","Echidna","Flamingo","Giraffe","Hedgehog","Iguana","Jellyfish","Koala","Lemur","Manatee","Narwhal","Octopus","Penguin"];return`${e[Math.floor(Math.random()*e.length)]} ${t[Math.floor(Math.random()*t.length)]}`})(),r=t[Math.floor(Math.random()*t.length)],a=n.charAt(0),l=document.createElement("canvas");l.width=64,l.height=64;const o=l.getContext("2d");return o.fillStyle=r,o.beginPath(),o.arc(32,32,32,0,2*Math.PI),o.fill(),o.fillStyle="#FFFFFF",o.font="32px sans-serif",o.textAlign="center",o.textBaseline="middle",o.fillText(a,32,32),{name:n,url:"#",avatar:l.toDataURL()}},b=JSON.parse('{"UU":"activitypub/reactions"}');(0,r.registerBlockType)(b.UU,{deprecated:a,edit:function({attributes:e,__unstableLayoutClassNames:t}){const n=(0,o.useBlockProps)({className:t}),[r]=(0,i.useState)({likes:{label:(0,s.sprintf)(/* translators: %d: Number of likes */ /* translators: %d: Number of likes */
-(0,s._x)("%d likes","number of likes","activitypub"),9),items:Array.from({length:9},((e,t)=>v()))},reposts:{label:(0,s.sprintf)(/* translators: %d: Number of reposts */ /* translators: %d: Number of reposts */
-(0,s._x)("%d reposts","number of reposts","activitypub"),6),items:Array.from({length:6},((e,t)=>v()))}}),a=[["core/heading",{level:6,placeholder:(0,s.__)("Fediverse Reactions","activitypub"),content:(0,s.__)("Fediverse Reactions","activitypub")}]];return(0,l.createElement)("div",{...n},(0,l.createElement)(o.InnerBlocks,{template:a,allowedBlocks:["core/heading"],templateLock:!1}),(0,l.createElement)(g,{reactions:r}))},save:function(){return(0,l.createElement)(l.Fragment,null,(0,l.createElement)(o.InnerBlocks.Content,null),(0,l.createElement)("div",{className:"activitypub-reactions-block"}))}})}},n={};function r(e){var a=n[e];if(void 0!==a)return a.exports;var l=n[e]={exports:{}};return t[e](l,l.exports,r),l.exports}r.m=t,e=[],r.O=(t,n,a,l)=>{if(!n){var o=1/0;for(u=0;u=l)&&Object.keys(r.O).every((e=>r.O[e](n[s])))?n.splice(s--,1):(i=!1,l0&&e[u-1][2]>l;u--)e[u]=e[u-1];e[u]=[n,a,l]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={608:0,104:0};r.O.j=t=>0===e[t];var t=(t,n)=>{var a,l,[o,i,s]=n,c=0;if(o.some((t=>0!==e[t]))){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(s)var u=s(r)}for(t&&t(n);cr(29)));a=r.O(a)})();
\ No newline at end of file
diff --git a/build/reactions/style-index-rtl.css b/build/reactions/style-index-rtl.css
deleted file mode 100644
index 728c87784..000000000
--- a/build/reactions/style-index-rtl.css
+++ /dev/null
@@ -1 +0,0 @@
-.wp-block-activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75em;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.wp-block-activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.wp-block-activitypub-reactions .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0;padding:0}.wp-block-activitypub-reactions .reaction-avatars li{margin:0 0 0 -10px;padding:0}.wp-block-activitypub-reactions .reaction-avatars li:last-child{margin-left:0}.wp-block-activitypub-reactions .reaction-avatars li a{display:block;text-decoration:none}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar{max-height:32px;max-width:32px;overflow:hidden;-moz-force-broken-image-icon:1;border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);transition:transform .6s cubic-bezier(.34,1.56,.64,1);will-change:transform}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar.wave-active{transform:translateY(-5px)}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-clockwise{transform:translateY(-5px) rotate(-30deg)}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-counter{transform:translateY(-5px) rotate(30deg)}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar:hover{position:relative;z-index:1}.wp-block-activitypub-reactions .reaction-label.components-button{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#2271b1);flex:0 0 auto;height:auto;padding:0;text-decoration:none;white-space:nowrap}.wp-block-activitypub-reactions .reaction-label.components-button:hover{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#135e96);text-decoration:underline}.wp-block-activitypub-reactions .reaction-label.components-button:focus:not(:disabled){box-shadow:none;outline:1px solid var(--wp--preset--color--contrast,#135e96);outline-offset:2px}.activitypub-reaction-list{background-color:var(--wp--preset--color--background,var(--wp--preset--color--custom-background,var(--wp--preset--color--base)));list-style:none;margin:0;max-width:300px;padding:.25em .7em .25em 1.3em;width:-moz-max-content;width:max-content}.activitypub-reaction-list ul{margin:0;padding:0}.activitypub-reaction-list li{font-size:var(--wp--preset--font-size--small);margin:0;padding:0}.activitypub-reaction-list a{align-items:center;color:var(--wp--preset--color--contrast,var(--wp--preset--color--secondary));display:flex;font-size:var(--wp--preset--font-size--small,.75rem);gap:.5em;justify-content:flex-start;padding:.5em;text-decoration:none}.activitypub-reaction-list a:hover{text-decoration:underline}.activitypub-reaction-list a img{border-radius:50%;flex:none;height:24px;width:24px}
diff --git a/build/reactions/style-index.css b/build/reactions/style-index.css
deleted file mode 100644
index 6776730f0..000000000
--- a/build/reactions/style-index.css
+++ /dev/null
@@ -1 +0,0 @@
-.wp-block-activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75em;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.wp-block-activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.wp-block-activitypub-reactions .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0;padding:0}.wp-block-activitypub-reactions .reaction-avatars li{margin:0 -10px 0 0;padding:0}.wp-block-activitypub-reactions .reaction-avatars li:last-child{margin-right:0}.wp-block-activitypub-reactions .reaction-avatars li a{display:block;text-decoration:none}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar{max-height:32px;max-width:32px;overflow:hidden;-moz-force-broken-image-icon:1;border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);transition:transform .6s cubic-bezier(.34,1.56,.64,1);will-change:transform}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar.wave-active{transform:translateY(-5px)}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-clockwise{transform:translateY(-5px) rotate(30deg)}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-counter{transform:translateY(-5px) rotate(-30deg)}.wp-block-activitypub-reactions .reaction-avatars .reaction-avatar:hover{position:relative;z-index:1}.wp-block-activitypub-reactions .reaction-label.components-button{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#2271b1);flex:0 0 auto;height:auto;padding:0;text-decoration:none;white-space:nowrap}.wp-block-activitypub-reactions .reaction-label.components-button:hover{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#135e96);text-decoration:underline}.wp-block-activitypub-reactions .reaction-label.components-button:focus:not(:disabled){box-shadow:none;outline:1px solid var(--wp--preset--color--contrast,#135e96);outline-offset:2px}.activitypub-reaction-list{background-color:var(--wp--preset--color--background,var(--wp--preset--color--custom-background,var(--wp--preset--color--base)));list-style:none;margin:0;max-width:300px;padding:.25em 1.3em .25em .7em;width:-moz-max-content;width:max-content}.activitypub-reaction-list ul{margin:0;padding:0}.activitypub-reaction-list li{font-size:var(--wp--preset--font-size--small);margin:0;padding:0}.activitypub-reaction-list a{align-items:center;color:var(--wp--preset--color--contrast,var(--wp--preset--color--secondary));display:flex;font-size:var(--wp--preset--font-size--small,.75rem);gap:.5em;justify-content:flex-start;padding:.5em;text-decoration:none}.activitypub-reaction-list a:hover{text-decoration:underline}.activitypub-reaction-list a img{border-radius:50%;flex:none;height:24px;width:24px}
diff --git a/build/reactions/view.asset.php b/build/reactions/view.asset.php
deleted file mode 100644
index 2f63d937d..000000000
--- a/build/reactions/view.asset.php
+++ /dev/null
@@ -1 +0,0 @@
- array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n'), 'version' => '2f19592adb33b4030d68');
diff --git a/build/reactions/view.js b/build/reactions/view.js
deleted file mode 100644
index 666da2de3..000000000
--- a/build/reactions/view.js
+++ /dev/null
@@ -1 +0,0 @@
-(()=>{"use strict";var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var r in n)e.o(n,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:n[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,n=window.wp.element,r=window.wp.domReady;var a=e.n(r);const c=window.wp.components,o=window.wp.apiFetch;var l=e.n(o);function s(){return window._activityPubOptions||{}}window.wp.i18n;const i=({reactions:e})=>{const{defaultAvatarUrl:r}=s(),[a,c]=(0,n.useState)(new Set),[o,l]=(0,n.useState)(new Map),i=(0,n.useRef)([]),u=()=>{i.current.forEach((e=>clearTimeout(e))),i.current=[]},m=(t,n)=>{u();const r=100,a=e.length;n&&l((e=>{const n=new Map(e);return n.set(t,"clockwise"),n}));const o=e=>{const o="right"===e,s=o?a-1:0,u=o?1:-1;for(let e=o?t:t-1;o?e<=s:e>=s;e+=u){const a=Math.abs(e-t),o=setTimeout((()=>{c((t=>{const r=new Set(t);return n?r.add(e):r.delete(e),r})),n&&e!==t&&l((t=>{const n=new Map(t),r=e-u,a=n.get(r);return n.set(e,"clockwise"===a?"counter":"clockwise"),n}))}),a*r);i.current.push(o)}};if(o("right"),o("left"),!n){const e=Math.max((a-t)*r,t*r),n=setTimeout((()=>{l(new Map)}),e+r);i.current.push(n)}};return(0,n.useEffect)((()=>()=>u()),[]),(0,t.createElement)("ul",{className:"reaction-avatars"},e.map(((e,n)=>{const c=o.get(n),l=["reaction-avatar",a.has(n)?"wave-active":"",c?`rotate-${c}`:""].filter(Boolean).join(" "),s=e.avatar||r;return(0,t.createElement)("li",{key:n},(0,t.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",onMouseEnter:()=>m(n,!0),onMouseLeave:()=>m(n,!1)},(0,t.createElement)("img",{src:s,alt:e.name,className:l,width:"32",height:"32",onError:e=>{e.target.src=r}})))})))},u=({reactions:e,type:n})=>(0,t.createElement)("ul",{className:"activitypub-reaction-list"},e.map(((e,n)=>(0,t.createElement)("li",{key:n},(0,t.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,t.createElement)("img",{src:e.avatar,alt:e.name,width:"32",height:"32"}),(0,t.createElement)("span",null,e.name)))))),m=({items:e,label:r})=>{const[a,o]=(0,n.useState)(!1),[l,s]=(0,n.useState)(null),[m,p]=(0,n.useState)(e.length),h=(0,n.useRef)(null);(0,n.useEffect)((()=>{if(!h.current)return;const t=()=>{const t=h.current;if(!t)return;const n=t.offsetWidth-(l?.offsetWidth||0)-12,r=Math.max(1,Math.floor((n-32)/22));p(Math.min(r,e.length))};t();const n=new ResizeObserver(t);return n.observe(h.current),()=>{n.disconnect()}}),[l,e.length]);const f=e.slice(0,m);return(0,t.createElement)("div",{className:"reaction-group",ref:h},(0,t.createElement)(i,{reactions:f}),(0,t.createElement)(c.Button,{ref:s,className:"reaction-label is-link",onClick:()=>o(!a),"aria-expanded":a},r),a&&l&&(0,t.createElement)(c.Popover,{anchor:l,onClose:()=>o(!1)},(0,t.createElement)(u,{reactions:e})))};function p({postId:e=null,reactions:r=null,title:a=null}){const{namespace:c}=s(),[o,i]=(0,n.useState)(r),[u,p]=(0,n.useState)(!r);return(0,n.useEffect)((()=>{if(r)return i(r),void p(!1);e?(p(!0),l()({path:`/${c}/posts/${e}/reactions`}).then((e=>{i(e),p(!1)})).catch((()=>p(!1)))):p(!1)}),[e,r]),u?null:o&&Object.values(o).some((e=>e.items?.length>0))?(0,t.createElement)(t.Fragment,null,a&&(0,t.createElement)("h6",null,a),Object.entries(o).map((([e,n])=>n.items?.length?(0,t.createElement)(m,{key:e,items:n.items,label:n.label}):null))):null}a()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-reactions-block"),(e=>{const r=JSON.parse(e.dataset.attrs||e.parentElement.dataset.attrs);(0,n.createRoot)(e).render((0,t.createElement)(p,{...r}))}))}))})();
\ No newline at end of file
diff --git a/build/reply/index.js b/build/reply/index.js
deleted file mode 100644
index c8ee59e4e..000000000
--- a/build/reply/index.js
+++ /dev/null
@@ -1 +0,0 @@
-(()=>{"use strict";var e,t={20:(e,t,r)=>{var n=r(609),o=Symbol.for("react.element"),a=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),i=n.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,l={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,r){var n,c={},s=null,d=null;for(n in void 0!==r&&(s=""+r),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(d=t.ref),t)a.call(t,n)&&!l.hasOwnProperty(n)&&(c[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps)void 0===c[n]&&(c[n]=t[n]);return{$$typeof:o,type:e,key:s,ref:d,props:c,_owner:i.current}}},238:(e,t,r)=>{const n=window.wp.blocks,o=window.wp.primitives;var a=r(848);const i=(0,a.jsx)(o.SVG,{width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:(0,a.jsx)(o.Path,{d:"M6.68822 10.625L6.24878 11.0649L5.5 11.8145L5.5 5.5L12.5 5.5V8L14 6.5V5C14 4.44772 13.5523 4 13 4H5C4.44772 4 4 4.44771 4 5V13.5247C4 13.8173 4.16123 14.086 4.41935 14.2237C4.72711 14.3878 5.10601 14.3313 5.35252 14.0845L7.31 12.125H8.375L9.875 10.625H7.31H6.68822ZM14.5605 10.4983L11.6701 13.75H16.9975C17.9963 13.75 18.7796 14.1104 19.3553 14.7048C19.9095 15.2771 20.2299 16.0224 20.4224 16.7443C20.7645 18.0276 20.7543 19.4618 20.7487 20.2544C20.7481 20.345 20.7475 20.4272 20.7475 20.4999L19.2475 20.5001C19.2475 20.4191 19.248 20.3319 19.2484 20.2394V20.2394C19.2526 19.4274 19.259 18.2035 18.973 17.1307C18.8156 16.5401 18.586 16.0666 18.2778 15.7483C17.9909 15.4521 17.5991 15.25 16.9975 15.25H11.8106L14.5303 17.9697L13.4696 19.0303L8.96956 14.5303L13.4394 9.50171L14.5605 10.4983Z"})});var l=r(609);const c=window.wp.blockEditor,s=window.wp.components,d=window.wp.i18n,u=window.wp.element,m=window.wp.compose,p=window.wp.apiFetch;var f=r.n(p);const h=window.wp.url,w=window.wp.data;function b({html:e}){const t=(0,u.useRef)(null),[r,n]=(0,u.useState)(300),o=(0,u.useRef)(300),a=(0,u.useCallback)((()=>{if(t.current)try{const e=t.current;let r=300;try{e.contentDocument&&e.contentDocument.body?r=e.contentDocument.body.scrollHeight:e.contentWindow&&e.contentWindow.document&&e.contentWindow.document.body&&(r=e.contentWindow.document.body.scrollHeight)}catch(e){console.log("Could not access iframe content document:",e)}r+=5,Math.abs(r-o.current)>5&&(o.current=r,n(r))}catch(e){console.error("Error adjusting iframe height:",e)}}),[]),i=(0,u.useCallback)((()=>{if(t.current)try{a()}catch(e){console.error("Error setting up iframe height adjustment:",e)}}),[a]);return(0,u.useEffect)((()=>{t.current&&t.current.addEventListener("load",i);const e=setInterval(a,1e3);return()=>{clearInterval(e),t.current&&t.current.removeEventListener("load",i)}}),[i,a]),(0,u.useEffect)((()=>{if(t.current){const e=setTimeout((()=>{a()}),100);return()=>clearTimeout(e)}}),[e,a]),{iframeRef:t,iframeHeight:r,adjustIframeHeight:a,handleIframeLoad:i}}const v={class:"className",frameborder:"frameBorder",allowfullscreen:"allowFullScreen",allowtransparency:"allowTransparency",marginheight:"marginHeight",marginwidth:"marginWidth"};function y({onClick:e}){return(0,l.createElement)("div",{className:"activitypub-embed-overlay",onClick:e,style:{position:"absolute",top:0,left:0,width:"100%",height:"100%",cursor:"pointer",zIndex:1}})}function g({html:e,onSelectBlock:t}){const r=(0,u.useRef)(),[n,o]=(0,u.useState)(282),[a,i]=(0,u.useState)(!1),c=(0,u.useCallback)((()=>{const t=(new window.DOMParser).parseFromString(e,"text/html").querySelector("iframe"),r={};return t?(Array.from(t.attributes).forEach((({name:e,value:t})=>{"style"!==e&&(r[v[e]||e]=t)})),r):r}),[e]),s=c();return(0,u.useEffect)((()=>{if(!r.current)return;const{ownerDocument:e}=r.current,{defaultView:t}=e;function n({data:{secret:e,message:t,value:r}={}}){"height"===t&&e===s["data-secret"]&&o(r)}return t.addEventListener("message",n),()=>{t.removeEventListener("message",n)}}),[s]),s.src?(0,l.createElement)("div",{className:"wp-block-embed__wrapper",style:{position:"relative"}},(0,l.createElement)("iframe",{ref:r,title:s.title||(0,d.__)("Embedded WordPress content","activitypub"),...s,height:n,style:{width:"100%",maxWidth:"100%"}}),!a&&(0,l.createElement)(y,{onClick:t})):(0,l.createElement)("div",{className:"wp-block-embed__wrapper",style:{position:"relative"}},(0,l.createElement)("div",{dangerouslySetInnerHTML:{__html:e}}),(0,l.createElement)(y,{onClick:t}))}function _({html:e,onClick:t,isSelected:r}){const{iframeRef:n,iframeHeight:o,adjustIframeHeight:a,handleIframeLoad:i}=b({html:e}),c=(0,u.useCallback)((()=>`\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t \n\t\t\t\t \n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t${e}\n\t\t\t\n\t\t\t\n\t\t`),[e]);return(0,l.createElement)("div",{className:"wp-block-embed__wrapper",style:{position:"relative"}},(0,l.createElement)("iframe",{ref:n,srcDoc:c(),sandbox:"allow-scripts allow-same-origin allow-popups allow-forms",style:{width:"100%",height:`${o}px`,border:"none",overflow:"hidden"},onLoad:i}),r&&(0,l.createElement)("div",{onClick:t,style:{position:"absolute",top:0,left:0,width:"100%",height:"100%",cursor:"pointer",zIndex:1,display:r?"block":"none"}}))}const E={default:(0,d.__)("Enter the URL of a post from the Fediverse (Mastodon, Pixelfed, etc.) that you want to reply to.","activitypub"),checking:()=>(0,l.createElement)(l.Fragment,null,(0,l.createElement)(s.Spinner,null)," "+(0,d.__)("Checking if this URL supports ActivityPub replies...","activitypub")),valid:(0,d.__)("The author will be notified of your response.","activitypub"),error:(0,d.__)("This URL probably won't receive your reply. We'll still try.","activitypub")},k={valid:(0,d.__)("This post can be embedded with your reply.","activitypub"),invalid:(0,d.__)("This post cannot be embedded.","activitypub")};(0,n.registerBlockType)("activitypub/reply",{edit:function({attributes:e,setAttributes:t,clientId:r,isSelected:n}){const{url:o}=e,{namespace:a}=window._activityPubOptions||{},[i,p]=(0,u.useState)(E.default),[v,y]=(0,u.useState)(!1),[C,L]=(0,u.useState)(!1),[S,O]=(0,u.useState)(!1),[P,x]=(0,u.useState)(!0===e.embedPost||!o),[R,T]=(0,u.useState)(null),{iframeRef:H,iframeHeight:I,adjustIframeHeight:j,handleIframeLoad:D}=b({html:R}),{insertAfterBlock:B,removeBlock:N}=(0,w.useDispatch)("core/block-editor"),W=(0,c.useBlockProps)(),F=(0,u.useRef)(),M=((0,u.useRef)(),(0,u.useRef)(P)),U=()=>{setTimeout((()=>F.current?.focus()),50)};(0,u.useEffect)((()=>{M.current=P}),[P]);const A=(0,u.useCallback)((e=>{y(e),M.current&&e&&t({embedPost:!0})}),[t]),V=(e=!1)=>{O(e),y(!1),L(!1),T("")},$=(0,m.useDebounce)((async e=>{if(e)try{V(!0),p(E.checking());const t=await f()({path:(0,h.addQueryArgs)(`${a}/url/validate`,{url:e})});A(t.is_activitypub),L(t.is_real_oembed),T(t.html||""),p(E.valid)}catch(e){V(),p(E.error)}finally{O(!1)}else V()}),250);return(0,u.useEffect)((()=>{o&&$(o)}),[o]),(0,l.createElement)(l.Fragment,null,(0,l.createElement)(c.InspectorControls,null,(0,l.createElement)(s.PanelBody,{title:(0,d.__)("Settings","activitypub")},(0,l.createElement)(s.ToggleControl,{label:(0,d.__)("Embed Post","activitypub"),checked:e.embedPost,onChange:e=>{t({embedPost:e}),x(e)},disabled:!v,help:v?k.valid:k.invalid}))),(0,l.createElement)("div",{...W},n&&(0,l.createElement)(s.TextControl,{label:(0,d.__)("Your post is a reply to the following URL","activitypub"),value:o,onChange:e=>t({url:e}),help:i,onKeyDown:t=>{"Enter"===t.key&&B(r),!e.url&&["Backspace","Delete"].includes(t.key)&&N(r)},ref:F}),v&&e.embedPost&&R&&(0,l.createElement)("div",{className:"activitypub-embed-container"},C&&(Y=R)&&(Y.includes("wp-embedded-content")||Y.includes("wp-embed/")||Y.includes('class="wp-embed"'))?(0,l.createElement)(g,{html:R,onSelectBlock:U}):(0,l.createElement)(_,{html:R,onClick:U,isSelected:n})),o&&(!e.embedPost||!R)&&(0,l.createElement)("div",{className:"activitypub-reply-block-editor__preview",contentEditable:!1,onClick:U,style:{cursor:"pointer"}},(0,l.createElement)("a",{href:o,className:"u-in-reply-to",target:"_blank",rel:"noreferrer"},"↬"+o.replace(/^https?:\/\//,"")))));var Y},save:()=>null,icon:i})},609:e=>{e.exports=window.React},848:(e,t,r)=>{e.exports=r(20)}},r={};function n(e){var o=r[e];if(void 0!==o)return o.exports;var a=r[e]={exports:{}};return t[e](a,a.exports,n),a.exports}n.m=t,e=[],n.O=(t,r,o,a)=>{if(!r){var i=1/0;for(d=0;d=a)&&Object.keys(n.O).every((e=>n.O[e](r[c])))?r.splice(c--,1):(l=!1,a0&&e[d-1][2]>a;d--)e[d]=e[d-1];e[d]=[r,o,a]},n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={780:0,356:0};n.O.j=t=>0===e[t];var t=(t,r)=>{var o,a,[i,l,c]=r,s=0;if(i.some((t=>0!==e[t]))){for(o in l)n.o(l,o)&&(n.m[o]=l[o]);if(c)var d=c(n)}for(t&&t(r);sn(238)));o=n.O(o)})();
\ No newline at end of file
diff --git a/build/reply/style-index-rtl.css b/build/reply/style-index-rtl.css
deleted file mode 100644
index ec651ab69..000000000
--- a/build/reply/style-index-rtl.css
+++ /dev/null
@@ -1 +0,0 @@
-.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}
diff --git a/build/reply/style-index.css b/build/reply/style-index.css
deleted file mode 100644
index ec651ab69..000000000
--- a/build/reply/style-index.css
+++ /dev/null
@@ -1 +0,0 @@
-.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}
diff --git a/build/wp-admin/admin-rtl.css b/build/wp-admin/admin-rtl.css
new file mode 100644
index 000000000..31baaa4ed
--- /dev/null
+++ b/build/wp-admin/admin-rtl.css
@@ -0,0 +1,3 @@
+.activitypub-settings{margin:0 auto;max-width:800px;position:relative}.settings_page_activitypub div:not(.wrap)>.notice{margin:0 auto 30px;max-width:800px}.settings_page_activitypub div:not(.wrap)>.update-nag{margin:25px 22px 15px 20px}.settings_page_activitypub .wrap{padding-right:22px}.activitypub-settings p.interactions{margin-bottom:1em}.activitypub-settings-header{background:#fff;border-bottom:1px solid #dcdcde;margin:0 0 1rem;text-align:center}.activitypub-settings-header h1{display:inline-block;font-size:23px;font-weight:600;line-height:1.3;margin:0 .8rem 1rem;padding:9px 0 4px}.activitypub-settings-title-section{align-items:center;clear:both;display:flex;justify-content:center;padding-top:8px}.settings_page_activitypub #wpcontent{padding-right:0}.activitypub-settings-tabs-scroller{overflow-x:auto;padding-top:2px;width:100%;-webkit-overflow-scrolling:touch;scroll-behavior:smooth}.activitypub-settings-tabs-wrapper{display:inline-flex;flex-wrap:nowrap;gap:0;vertical-align:top}.activitypub-settings-tab.active{box-shadow:inset 0 -3px #3582c4;font-weight:600}.activitypub-settings-tab{color:inherit;display:block;margin:0 1rem;padding:.5rem 1rem 1rem;text-decoration:none;transition:box-shadow .5s ease-in-out;white-space:nowrap}.activitypub-settings .row{margin-bottom:16px}.activitypub-settings .row>div{display:inline-flex;flex-direction:column;max-width:calc(100% - 24px)}.activitypub-settings .row .description{margin-top:0}.wp-header-end{margin:-2px 0 0;visibility:hidden}summary{color:#2271b1;cursor:pointer;text-decoration:underline}.activitypub-settings-accordion{border:1px solid #c3c4c7}.activitypub-settings-accordion-heading{border-top:1px solid #c3c4c7;color:inherit;font-size:inherit;font-weight:600;line-height:inherit;margin:0}.activitypub-settings-accordion-heading:first-child{border-top:none}.activitypub-settings-accordion-panel{background:#fff;margin:0;padding:1em 1.5em}.activitypub-settings-accordion-trigger{align-items:center;background:#fff;border:0;color:#2c3338;cursor:pointer;display:flex;font-weight:400;justify-content:space-between;margin:0;min-height:46px;padding:1em 1.5em 1em 3.5em;position:relative;text-align:right;-webkit-user-select:auto;-moz-user-select:auto;user-select:auto;width:100%}.activitypub-settings-accordion-trigger .title{flex-grow:1;font-weight:600;pointer-events:none}.activitypub-settings-accordion-trigger .icon,.activitypub-settings-accordion-viewed .icon{border:solid #50575e;border-width:0 0 2px 2px;height:.5rem;pointer-events:none;position:absolute;left:1.5em;top:50%;transform:translateY(-70%) rotate(-45deg);width:.5rem}.activitypub-settings-accordion-trigger[aria-expanded=true] .icon{transform:translateY(-30%) rotate(135deg)}.activitypub-settings-accordion-trigger:active,.activitypub-settings-accordion-trigger:hover{background:#f6f7f7}.activitypub-settings-accordion-trigger:focus{background-color:#f6f7f7;border:none;box-shadow:none;color:#1d2327;outline:2px solid #2271b1;outline-offset:-1px}.activitypub-settings
+input.blog-user-identifier{text-align:left}.activitypub-settings
+.header-image{background-image:#a8a5af;background-image:linear-gradient(-180deg,red,#ff0);background-size:cover;display:block;height:80px;margin-bottom:40px;position:relative;width:100%}.activitypub-settings .logo{height:80px;right:40px;position:relative;top:40px;width:80px}.settings_page_activitypub .plugin-recommendations{border-bottom:none;margin-bottom:0}#dashboard_right_now li a.activitypub-followers:before{content:"\f307";font-family:dashicons}.like .dashboard-comment-wrap,.repost .dashboard-comment-wrap{padding-inline-start:63px}.like .dashboard-comment-wrap .comment-author,.repost .dashboard-comment-wrap .comment-author{margin-block:0}.extra-fields-nav a+a{margin-right:8px}.rtl .extra-fields-nav a+a{margin-right:auto;margin-left:8px}.contextual-help-tabs-wrap dt{font-weight:600}.contextual-help-tabs-wrap .activitypub-block-screenshot{margin:10px 0}.contextual-help-tabs-wrap .activitypub-block-screenshot img{border:1px solid #ddd;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1);height:auto;max-width:100%}.contextual-help-tabs-wrap .activitypub-block-screenshot figcaption{color:#555;font-size:.9em;font-style:italic;margin-top:5px}.contextual-help-tabs-wrap blockquote{background-color:#f6f7f7;border-right:4px solid #3582c4;margin:0 0 20px;padding:16px 20px}.contextual-help-tabs-wrap blockquote p{line-height:1.5;margin:0 0 10px}.contextual-help-tabs-wrap blockquote p:last-child{margin-bottom:0}.contextual-help-tabs-wrap blockquote cite{color:#50575e;display:block;font-size:.9em;font-weight:600;margin-top:8px}.contextual-help-tabs-wrap blockquote cite:before{content:"—"}.plugin-list{align-items:stretch;display:flex;flex-wrap:wrap;gap:16px}.plugin-list .plugin-card{border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1);box-sizing:border-box;display:flex;flex:1 1 300px;flex-direction:column;margin:0}.plugin-list .plugin-card .desc{flex:1 1 auto}.plugin-list .plugin-action-buttons li{margin:0 0 10px}.plugin-list .plugin-card .action-links{margin-right:148px;position:static;width:auto}.plugin-list .plugin-action-buttons{float:none;margin:1em 0 0;text-align:right}.plugin-list .plugin-action-buttons li{display:inline-block;vertical-align:middle}.plugin-list .plugin-action-buttons li .button{margin-left:20px}.plugin-list .plugin-card h3{margin-left:24px}.plugin-card .desc>p,.plugin-list .plugin-card .desc,.plugin-list .plugin-card .name{margin-left:0}.plugin-list .plugin-card .desc p:first-of-type{margin-top:0}.rtl .contextual-help-tabs-wrap blockquote{border-right:none;border-left:4px solid #3582c4;padding:16px 20px}#activitypub-follow-form .highlight{animation:highlight-fade 3s ease-in-out;border-color:#3582c4!important;box-shadow:0 0 0 1px #3582c4}@keyframes highlight-fade{0%{background-color:#e7f3ff;border-color:#3582c4;box-shadow:0 0 0 1px #3582c4}to{background-color:#fff;border-color:#8c8f94;box-shadow:none}}@media screen and (max-width:782px){.activitypub-settings{margin:0 22px}.activitypub-settings .row>div{max-width:calc(100% - 36px);width:100%}}
diff --git a/build/wp-admin/admin.css b/build/wp-admin/admin.css
new file mode 100644
index 000000000..0fec908dd
--- /dev/null
+++ b/build/wp-admin/admin.css
@@ -0,0 +1,3 @@
+.activitypub-settings{margin:0 auto;max-width:800px;position:relative}.settings_page_activitypub div:not(.wrap)>.notice{margin:0 auto 30px;max-width:800px}.settings_page_activitypub div:not(.wrap)>.update-nag{margin:25px 20px 15px 22px}.settings_page_activitypub .wrap{padding-left:22px}.activitypub-settings p.interactions{margin-bottom:1em}.activitypub-settings-header{background:#fff;border-bottom:1px solid #dcdcde;margin:0 0 1rem;text-align:center}.activitypub-settings-header h1{display:inline-block;font-size:23px;font-weight:600;line-height:1.3;margin:0 .8rem 1rem;padding:9px 0 4px}.activitypub-settings-title-section{align-items:center;clear:both;display:flex;justify-content:center;padding-top:8px}.settings_page_activitypub #wpcontent{padding-left:0}.activitypub-settings-tabs-scroller{overflow-x:auto;padding-top:2px;width:100%;-webkit-overflow-scrolling:touch;scroll-behavior:smooth}.activitypub-settings-tabs-wrapper{display:inline-flex;flex-wrap:nowrap;gap:0;vertical-align:top}.activitypub-settings-tab.active{box-shadow:inset 0 -3px #3582c4;font-weight:600}.activitypub-settings-tab{color:inherit;display:block;margin:0 1rem;padding:.5rem 1rem 1rem;text-decoration:none;transition:box-shadow .5s ease-in-out;white-space:nowrap}.activitypub-settings .row{margin-bottom:16px}.activitypub-settings .row>div{display:inline-flex;flex-direction:column;max-width:calc(100% - 24px)}.activitypub-settings .row .description{margin-top:0}.wp-header-end{margin:-2px 0 0;visibility:hidden}summary{color:#2271b1;cursor:pointer;text-decoration:underline}.activitypub-settings-accordion{border:1px solid #c3c4c7}.activitypub-settings-accordion-heading{border-top:1px solid #c3c4c7;color:inherit;font-size:inherit;font-weight:600;line-height:inherit;margin:0}.activitypub-settings-accordion-heading:first-child{border-top:none}.activitypub-settings-accordion-panel{background:#fff;margin:0;padding:1em 1.5em}.activitypub-settings-accordion-trigger{align-items:center;background:#fff;border:0;color:#2c3338;cursor:pointer;display:flex;font-weight:400;justify-content:space-between;margin:0;min-height:46px;padding:1em 3.5em 1em 1.5em;position:relative;text-align:left;-webkit-user-select:auto;-moz-user-select:auto;user-select:auto;width:100%}.activitypub-settings-accordion-trigger .title{flex-grow:1;font-weight:600;pointer-events:none}.activitypub-settings-accordion-trigger .icon,.activitypub-settings-accordion-viewed .icon{border:solid #50575e;border-width:0 2px 2px 0;height:.5rem;pointer-events:none;position:absolute;right:1.5em;top:50%;transform:translateY(-70%) rotate(45deg);width:.5rem}.activitypub-settings-accordion-trigger[aria-expanded=true] .icon{transform:translateY(-30%) rotate(-135deg)}.activitypub-settings-accordion-trigger:active,.activitypub-settings-accordion-trigger:hover{background:#f6f7f7}.activitypub-settings-accordion-trigger:focus{background-color:#f6f7f7;border:none;box-shadow:none;color:#1d2327;outline:2px solid #2271b1;outline-offset:-1px}.activitypub-settings
+input.blog-user-identifier{text-align:right}.activitypub-settings
+.header-image{background-image:#a8a5af;background-image:linear-gradient(180deg,red,#ff0);background-size:cover;display:block;height:80px;margin-bottom:40px;position:relative;width:100%}.activitypub-settings .logo{height:80px;left:40px;position:relative;top:40px;width:80px}.settings_page_activitypub .plugin-recommendations{border-bottom:none;margin-bottom:0}#dashboard_right_now li a.activitypub-followers:before{content:"\f307";font-family:dashicons}.like .dashboard-comment-wrap,.repost .dashboard-comment-wrap{padding-inline-start:63px}.like .dashboard-comment-wrap .comment-author,.repost .dashboard-comment-wrap .comment-author{margin-block:0}.extra-fields-nav a+a{margin-left:8px}.rtl .extra-fields-nav a+a{margin-left:auto;margin-right:8px}.contextual-help-tabs-wrap dt{font-weight:600}.contextual-help-tabs-wrap .activitypub-block-screenshot{margin:10px 0}.contextual-help-tabs-wrap .activitypub-block-screenshot img{border:1px solid #ddd;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1);height:auto;max-width:100%}.contextual-help-tabs-wrap .activitypub-block-screenshot figcaption{color:#555;font-size:.9em;font-style:italic;margin-top:5px}.contextual-help-tabs-wrap blockquote{background-color:#f6f7f7;border-left:4px solid #3582c4;margin:0 0 20px;padding:16px 20px}.contextual-help-tabs-wrap blockquote p{line-height:1.5;margin:0 0 10px}.contextual-help-tabs-wrap blockquote p:last-child{margin-bottom:0}.contextual-help-tabs-wrap blockquote cite{color:#50575e;display:block;font-size:.9em;font-weight:600;margin-top:8px}.contextual-help-tabs-wrap blockquote cite:before{content:"—"}.plugin-list{align-items:stretch;display:flex;flex-wrap:wrap;gap:16px}.plugin-list .plugin-card{border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1);box-sizing:border-box;display:flex;flex:1 1 300px;flex-direction:column;margin:0}.plugin-list .plugin-card .desc{flex:1 1 auto}.plugin-list .plugin-action-buttons li{margin:0 0 10px}.plugin-list .plugin-card .action-links{margin-left:148px;position:static;width:auto}.plugin-list .plugin-action-buttons{float:none;margin:1em 0 0;text-align:left}.plugin-list .plugin-action-buttons li{display:inline-block;vertical-align:middle}.plugin-list .plugin-action-buttons li .button{margin-right:20px}.plugin-list .plugin-card h3{margin-right:24px}.plugin-card .desc>p,.plugin-list .plugin-card .desc,.plugin-list .plugin-card .name{margin-right:0}.plugin-list .plugin-card .desc p:first-of-type{margin-top:0}.rtl .contextual-help-tabs-wrap blockquote{border-left:none;border-right:4px solid #3582c4;padding:16px 20px}#activitypub-follow-form .highlight{animation:highlight-fade 3s ease-in-out;border-color:#3582c4!important;box-shadow:0 0 0 1px #3582c4}@keyframes highlight-fade{0%{background-color:#e7f3ff;border-color:#3582c4;box-shadow:0 0 0 1px #3582c4}to{background-color:#fff;border-color:#8c8f94;box-shadow:none}}@media screen and (max-width:782px){.activitypub-settings{margin:0 22px}.activitypub-settings .row>div{max-width:calc(100% - 36px);width:100%}}
diff --git a/build/wp-admin/header-image.asset.php b/build/wp-admin/header-image.asset.php
new file mode 100644
index 000000000..1af3a4e68
--- /dev/null
+++ b/build/wp-admin/header-image.asset.php
@@ -0,0 +1 @@
+ array('jquery'), 'version' => 'ee5b8c2099a3b65812a6');
diff --git a/build/wp-admin/header-image.js b/build/wp-admin/header-image.js
new file mode 100644
index 000000000..3a12fddb3
--- /dev/null
+++ b/build/wp-admin/header-image.js
@@ -0,0 +1,4 @@
+(()=>{"use strict";var t={n:e=>{var a=e&&e.__esModule?()=>e.default:()=>e;return t.d(a,{a}),a},d:(e,a)=>{for(var i in a)t.o(a,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:a[i]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const e=window.jQuery;!function(t){var e,a,i=t("#activitypub-choose-from-library-button"),r=t("#activitypub-header-image-preview-wrapper"),s=t("#activitypub-header-image-preview"),d=t("#activitypub_header_image"),n=t("#activitypub-remove-header-image");function h(t){var e,a,i=t.get("width"),r=t.get("height"),s=1500,d=500,n=s/d,h=s,o=d;return i/r>n?s=(d=r)*n:d=(s=i)/n,{aspectRatio:s+":"+d,handles:!0,keys:!0,instance:!0,persistent:!0,imageWidth:i,imageHeight:r,minWidth:h>s?s:h,minHeight:o>d?d:o,x1:e=(i-s)/2,y1:a=(r-d)/2,x2:s+e,y2:d+a}}function o(t){var e;t.alt?wp.i18n.sprintf(/* translators: %s: The selected image alt text. */
+wp.i18n.__("Header Image preview: Current image: %s"),t.alt):(e=wp.i18n.sprintf(/* translators: %s: The selected image filename. */
+wp.i18n.__("Header Image preview: The current image has no alternative text. The file name is: %s"),t.filename),wp.i18n.sprintf(/* translators: %s: The selected image filename. */
+wp.i18n.__("Header Image preview: The current image has no alternative text. The file name is: %s"),t.filename)),s.attr({src:t.url,alt:e}),r.removeClass("hidden"),n.removeClass("hidden"),"1"!==i.attr("data-state")&&i.attr({class:i.attr("data-alt-classes"),"data-alt-classes":i.attr("class"),"data-state":"1"}),i.text(i.attr("data-update-text"))}a=wp.media.controller.CustomizeImageCropper.extend({doCrop:function(t){var e=t.get("cropDetails"),a=this.get("control"),i=e.width/e.height;return a.params.flex_width&&a.params.flex_height?(e.dst_width=e.width,e.dst_height=e.height):(e.dst_width=a.params.flex_width?a.params.height*i:a.params.width,e.dst_height=a.params.flex_height?a.params.width/i:a.params.height),wp.ajax.post("crop-image",{nonce:t.get("nonces").edit,id:t.get("id"),context:a.id,cropDetails:e})}}),i.on("click",(function(){var i=t(this),r=i.data("userId"),s={type:"image"};r&&(s.author=r),(e=wp.media({button:{text:i.data("update"),close:!1},states:[new wp.media.controller.Library({title:i.data("choose-text"),library:wp.media.query(s),date:!1,suggestedWidth:i.data("width"),suggestedHeight:i.data("height")}),new a({control:{id:"activitypub-header-image",params:{width:i.data("width"),height:i.data("height")}},imgSelectOptions:h})]})).on("cropped",(function(t){d.val(t.id),o(t),e.close(),e=null})),e.on("select",(function(){var t=e.state().get("selection").first(),a=i.data("width")/i.data("height"),r=t.attributes.width/t.attributes.height,s=!1;Math.abs(r-a)<.01&&(t.id!==parseInt(d.val(),10)&&d.val(t.id),s=!0),s?(o(t.attributes),e.close()):e.setState("cropper")})),e.open()})),n.on("click",(function(){d.val("false"),t(this).toggleClass("hidden"),r.toggleClass("hidden"),s.attr({src:"",alt:""}),i.attr({class:i.attr("data-alt-classes"),"data-alt-classes":i.attr("class"),"data-state":""}).text(i.attr("data-choose-text")).trigger("focus")}))}(t.n(e)())})();
\ No newline at end of file
diff --git a/build/wp-admin/post-preview-rtl.css b/build/wp-admin/post-preview-rtl.css
new file mode 100644
index 000000000..f0b922def
--- /dev/null
+++ b/build/wp-admin/post-preview-rtl.css
@@ -0,0 +1 @@
+body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:1em;line-height:1.5;margin:0;padding:0}main{background-color:#fff;border:1px solid #ccc;border-radius:4px;flex:1;margin:1em;max-width:600px}main p{margin-bottom:1em}hr{background:transparent;border:0;border-top:1px solid #ccc;flex:0 0 auto;margin:10px 0}.columns{display:flex;flex-direction:row;justify-content:space-between;margin:0 auto;max-width:1200px}.sidebar{flex:1;max-width:285px;padding:1em}.sidebar h1{background-color:#6364ff;border-radius:4px;color:#fff;display:inline-block;font-size:1.5em;margin-bottom:1em;margin-top:0;padding:5px 10px}.sidebar ul{list-style-type:none;padding:0}.sidebar ul li{color:#ccc;padding:5px}.sidebar input[type=search],.sidebar textarea{background-color:#f6f6f6;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;color:#333;display:block;font-size:1em;margin-bottom:1em;padding:.5em;width:100%}.sidebar>div,main address{align-items:center;display:flex;font-style:normal;margin-bottom:1em}main address .name,main address .webfinger{color:#000}.name,.webfinger{color:#ccc;display:block;font-weight:700}.webfinger{font-size:.8em;margin-top:.5em}.sidebar .fake-image,address img{background-color:#333;border-radius:8px;height:48px;margin-left:1em;width:48px}main article{padding:1em}main .content{margin:1em 0}main .content,main .content h2{font-size:1.2em}main .attachments{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0;min-height:64px;overflow:hidden;position:relative;width:100%}main .attachments.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}main .attachments.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}main .attachments.layout-3>img:first-child{grid-row:span 2}main .attachments img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}main .attachments audio,main .attachments video{display:block;grid-column:1/span 2;margin:1em 0;max-width:100%}main .attachments audio{width:100%}main .tags a{background-color:#f6f6f6;border-radius:4px;color:#333;display:inline-block;margin-left:.5em;padding:.5em;text-decoration:none}main .tags a:hover{background-color:#e6e6e6;text-decoration:underline}main .column-header{border-bottom:1px solid #ccc;font-size:1.5em;margin:0;padding:5px 10px;vertical-align:middle}
diff --git a/build/wp-admin/post-preview.css b/build/wp-admin/post-preview.css
new file mode 100644
index 000000000..2f6c5d371
--- /dev/null
+++ b/build/wp-admin/post-preview.css
@@ -0,0 +1 @@
+body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:1em;line-height:1.5;margin:0;padding:0}main{background-color:#fff;border:1px solid #ccc;border-radius:4px;flex:1;margin:1em;max-width:600px}main p{margin-bottom:1em}hr{background:transparent;border:0;border-top:1px solid #ccc;flex:0 0 auto;margin:10px 0}.columns{display:flex;flex-direction:row;justify-content:space-between;margin:0 auto;max-width:1200px}.sidebar{flex:1;max-width:285px;padding:1em}.sidebar h1{background-color:#6364ff;border-radius:4px;color:#fff;display:inline-block;font-size:1.5em;margin-bottom:1em;margin-top:0;padding:5px 10px}.sidebar ul{list-style-type:none;padding:0}.sidebar ul li{color:#ccc;padding:5px}.sidebar input[type=search],.sidebar textarea{background-color:#f6f6f6;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;color:#333;display:block;font-size:1em;margin-bottom:1em;padding:.5em;width:100%}.sidebar>div,main address{align-items:center;display:flex;font-style:normal;margin-bottom:1em}main address .name,main address .webfinger{color:#000}.name,.webfinger{color:#ccc;display:block;font-weight:700}.webfinger{font-size:.8em;margin-top:.5em}.sidebar .fake-image,address img{background-color:#333;border-radius:8px;height:48px;margin-right:1em;width:48px}main article{padding:1em}main .content{margin:1em 0}main .content,main .content h2{font-size:1.2em}main .attachments{border-radius:8px;box-sizing:border-box;display:grid;gap:2px;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;margin:1em 0;min-height:64px;overflow:hidden;position:relative;width:100%}main .attachments.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}main .attachments.layout-2{aspect-ratio:auto;grid-template-rows:1fr;height:auto}main .attachments.layout-3>img:first-child{grid-row:span 2}main .attachments img{border:0;box-sizing:border-box;display:inline-block;height:100%;-o-object-fit:cover;object-fit:cover;overflow:hidden;position:relative;width:100%}main .attachments audio,main .attachments video{display:block;grid-column:1/span 2;margin:1em 0;max-width:100%}main .attachments audio{width:100%}main .tags a{background-color:#f6f6f6;border-radius:4px;color:#333;display:inline-block;margin-right:.5em;padding:.5em;text-decoration:none}main .tags a:hover{background-color:#e6e6e6;text-decoration:underline}main .column-header{border-bottom:1px solid #ccc;font-size:1.5em;margin:0;padding:5px 10px;vertical-align:middle}
diff --git a/build/wp-admin/script.asset.php b/build/wp-admin/script.asset.php
new file mode 100644
index 000000000..c86a261fe
--- /dev/null
+++ b/build/wp-admin/script.asset.php
@@ -0,0 +1 @@
+ array('jquery'), 'version' => '8e8ff978d9eef5ac0a85');
diff --git a/build/wp-admin/script.js b/build/wp-admin/script.js
new file mode 100644
index 000000000..f537291bb
--- /dev/null
+++ b/build/wp-admin/script.js
@@ -0,0 +1 @@
+(()=>{"use strict";var t={n:e=>{var a=e&&e.__esModule?()=>e.default:()=>e;return t.d(a,{a}),a},d:(e,a)=>{for(var i in a)t.o(a,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:a[i]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const e=window.jQuery;t.n(e)()((function(t){t(".activitypub-settings-accordion").on("click",".activitypub-settings-accordion-trigger",(function(){"true"===t(this).attr("aria-expanded")?(t(this).attr("aria-expanded","false"),t("#"+t(this).attr("aria-controls")).attr("hidden",!0)):(t(this).attr("aria-expanded","true"),t("#"+t(this).attr("aria-controls")).attr("hidden",!1))})),t(document).on("wp-plugin-install-success",(function(e,a){setTimeout((function(){t(".activate-now").removeClass("thickbox open-plugin-details-modal")}),1200)}))}))})();
\ No newline at end of file
diff --git a/build/wp-admin/welcome-rtl.css b/build/wp-admin/welcome-rtl.css
new file mode 100644
index 000000000..e068ac32a
--- /dev/null
+++ b/build/wp-admin/welcome-rtl.css
@@ -0,0 +1 @@
+.activitypub-welcome-container{background-color:#fff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin:40px auto;max-width:800px;padding:30px}.activitypub-welcome-header{margin-bottom:30px;position:relative;text-align:center}.activitypub-progress-circle{height:120px;margin:0 auto 20px;position:relative;width:120px}.activitypub-progress-circle-content{align-items:center;color:#1e1e1e;display:flex;font-size:16px;font-weight:500;height:100%;justify-content:center;right:0;position:absolute;top:0;width:100%;z-index:2}.activitypub-progress-ring{overflow:visible;transform:rotate(90deg)}.activitypub-progress-ring-bg{fill:none;stroke:#f0f0f1;stroke-width:6}.activitypub-progress-ring-circle{fill:none;stroke:#2271b1;stroke-width:6;stroke-linecap:round;transition:stroke-dashoffset .5s ease}.activitypub-welcome-title{font-size:28px;font-weight:400;margin:20px 0 10px}.activitypub-welcome-subtitle{color:#646970;font-size:16px;font-weight:400;margin:0 0 20px}.activitypub-onboarding-step{align-items:center;background-color:#f6f7f7;border-radius:4px;display:flex;margin-bottom:15px;padding:20px;transition:background-color .2s ease}.activitypub-onboarding-step:last-child{margin-bottom:0}.activitypub-onboarding-step:hover{background-color:#f0f0f1}.activitypub-step-completed{background-color:#f0f7ee}.activitypub-step-completed:hover{background-color:#e2f1dc}.activitypub-step-completed .step-text h3{margin:0}.activitypub-step-completed .step-text h3:after{content:"."}.activitypub-step-completed .step-action,.activitypub-step-completed .step-text p{display:none}.step-indicator{flex-shrink:0;margin-left:15px}.step-icon{align-items:center;display:flex;font-size:24px;height:24px;justify-content:center;width:24px}.activitypub-step-completed .step-icon{color:#008a20}.dashicons-warning{color:#dba617}.step-content{align-items:center;display:flex;flex-grow:1;justify-content:space-between;width:100%}.step-text{flex-grow:1}.step-text h3{font-size:16px;font-weight:500;margin:0 0 5px}.step-text p{color:#646970;font-size:14px;margin:0}.step-action{flex-shrink:0;margin-right:20px}.step-action .button{min-width:120px;text-align:center}.activitypub-profiles-section{border-top:1px solid #f0f0f1;margin-top:40px;padding-top:30px}.profiles-description{color:#1e1e1e;font-size:16px;margin-bottom:20px}.activitypub-profiles-container{display:flex;flex-wrap:wrap;gap:20px;margin-bottom:30px}.activitypub-profile-card{background-color:#fff;border:1px solid #c3c4c7;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1);flex:1;min-width:300px}.profile-card-header{align-items:center;background-color:#f0f0f1;border-bottom:1px solid #c3c4c7;display:flex;padding:15px}.profile-icon{margin-left:10px}.profile-icon .dashicons{font-size:20px;height:20px;width:20px}.profile-card-header h3{font-size:16px;font-weight:500;margin:0}.profile-card-content{padding:15px}.profile-field{margin-bottom:15px}.profile-field label{color:#646970;display:block;font-size:13px;font-weight:500;margin-bottom:5px}.profile-field input{background-color:#f6f7f7;border:1px solid #dcdcde;border-radius:3px;font-size:13px;padding:8px;width:100%}.profile-description{color:#646970;font-size:13px;line-height:1.5;margin:15px 0}.profile-card-content .button{margin-top:10px;text-align:center;width:100%}.activitypub-welcome-footer{margin-top:30px;text-align:center}.skip-steps-link{color:#2271b1;font-size:14px;text-decoration:none}.skip-steps-link:hover{color:#135e96;text-decoration:underline}@media screen and (max-width:782px){.activitypub-welcome-container{margin:20px;padding:20px}.step-content{align-items:flex-start;flex-direction:column}.step-action{margin-right:0;margin-top:15px;width:100%}.step-action .button{text-align:center;width:100%}.activitypub-profiles-container{flex-direction:column}.activitypub-profile-card{width:100%}}
diff --git a/build/wp-admin/welcome.css b/build/wp-admin/welcome.css
new file mode 100644
index 000000000..06a56e9da
--- /dev/null
+++ b/build/wp-admin/welcome.css
@@ -0,0 +1 @@
+.activitypub-welcome-container{background-color:#fff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin:40px auto;max-width:800px;padding:30px}.activitypub-welcome-header{margin-bottom:30px;position:relative;text-align:center}.activitypub-progress-circle{height:120px;margin:0 auto 20px;position:relative;width:120px}.activitypub-progress-circle-content{align-items:center;color:#1e1e1e;display:flex;font-size:16px;font-weight:500;height:100%;justify-content:center;left:0;position:absolute;top:0;width:100%;z-index:2}.activitypub-progress-ring{overflow:visible;transform:rotate(-90deg)}.activitypub-progress-ring-bg{fill:none;stroke:#f0f0f1;stroke-width:6}.activitypub-progress-ring-circle{fill:none;stroke:#2271b1;stroke-width:6;stroke-linecap:round;transition:stroke-dashoffset .5s ease}.activitypub-welcome-title{font-size:28px;font-weight:400;margin:20px 0 10px}.activitypub-welcome-subtitle{color:#646970;font-size:16px;font-weight:400;margin:0 0 20px}.activitypub-onboarding-step{align-items:center;background-color:#f6f7f7;border-radius:4px;display:flex;margin-bottom:15px;padding:20px;transition:background-color .2s ease}.activitypub-onboarding-step:last-child{margin-bottom:0}.activitypub-onboarding-step:hover{background-color:#f0f0f1}.activitypub-step-completed{background-color:#f0f7ee}.activitypub-step-completed:hover{background-color:#e2f1dc}.activitypub-step-completed .step-text h3{margin:0}.activitypub-step-completed .step-text h3:after{content:"."}.activitypub-step-completed .step-action,.activitypub-step-completed .step-text p{display:none}.step-indicator{flex-shrink:0;margin-right:15px}.step-icon{align-items:center;display:flex;font-size:24px;height:24px;justify-content:center;width:24px}.activitypub-step-completed .step-icon{color:#008a20}.dashicons-warning{color:#dba617}.step-content{align-items:center;display:flex;flex-grow:1;justify-content:space-between;width:100%}.step-text{flex-grow:1}.step-text h3{font-size:16px;font-weight:500;margin:0 0 5px}.step-text p{color:#646970;font-size:14px;margin:0}.step-action{flex-shrink:0;margin-left:20px}.step-action .button{min-width:120px;text-align:center}.activitypub-profiles-section{border-top:1px solid #f0f0f1;margin-top:40px;padding-top:30px}.profiles-description{color:#1e1e1e;font-size:16px;margin-bottom:20px}.activitypub-profiles-container{display:flex;flex-wrap:wrap;gap:20px;margin-bottom:30px}.activitypub-profile-card{background-color:#fff;border:1px solid #c3c4c7;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1);flex:1;min-width:300px}.profile-card-header{align-items:center;background-color:#f0f0f1;border-bottom:1px solid #c3c4c7;display:flex;padding:15px}.profile-icon{margin-right:10px}.profile-icon .dashicons{font-size:20px;height:20px;width:20px}.profile-card-header h3{font-size:16px;font-weight:500;margin:0}.profile-card-content{padding:15px}.profile-field{margin-bottom:15px}.profile-field label{color:#646970;display:block;font-size:13px;font-weight:500;margin-bottom:5px}.profile-field input{background-color:#f6f7f7;border:1px solid #dcdcde;border-radius:3px;font-size:13px;padding:8px;width:100%}.profile-description{color:#646970;font-size:13px;line-height:1.5;margin:15px 0}.profile-card-content .button{margin-top:10px;text-align:center;width:100%}.activitypub-welcome-footer{margin-top:30px;text-align:center}.skip-steps-link{color:#2271b1;font-size:14px;text-decoration:none}.skip-steps-link:hover{color:#135e96;text-decoration:underline}@media screen and (max-width:782px){.activitypub-welcome-container{margin:20px;padding:20px}.step-content{align-items:flex-start;flex-direction:column}.step-action{margin-left:0;margin-top:15px;width:100%}.step-action .button{text-align:center;width:100%}.activitypub-profiles-container{flex-direction:column}.activitypub-profile-card{width:100%}}
diff --git a/composer.json b/composer.json
index b3aca80e3..dbfc7eb99 100644
--- a/composer.json
+++ b/composer.json
@@ -1,66 +1,66 @@
{
- "name": "pfefferle/wordpress-activitypub",
- "description": "The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.",
- "type": "wordpress-plugin",
- "require": {
- "php": ">=7.2",
- "composer/installers": "^1.0 || ^2.0"
- },
- "require-dev": {
- "automattic/jetpack-changelogger": "6.0.0",
- "phpunit/phpunit": "^8 || ^9",
- "phpcompatibility/php-compatibility": "*",
- "phpcompatibility/phpcompatibility-wp": "*",
- "squizlabs/php_codesniffer": "3.*",
- "wp-coding-standards/wpcs": "dev-develop",
- "yoast/phpunit-polyfills": "^4.0",
- "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
- "sirbrillig/phpcs-variable-analysis": "^2.11",
- "phpcsstandards/phpcsextra": "^1.1.0",
- "dms/phpunit-arraysubset-asserts": "^0.5.0"
- },
- "config": {
- "allow-plugins": true
- },
- "allow-plugins": {
- "composer/installers": true
- },
- "license": "MIT",
- "authors": [
- {
- "name": "Matthias Pfefferle",
- "email": "pfefferle@gmail.com"
- }
- ],
- "extra": {
- "installer-name": "activitypub",
- "changelogger": {
- "changes-dir": ".github/changelog/",
- "link-template": "https://github.com/Automattic/wordpress-activitypub/compare/${old}...${new}"
- }
- },
- "scripts": {
- "test": [
- "composer install",
- "bin/install-wp-tests.sh activitypub-test root activitypub-test test-db latest true",
- "vendor/bin/phpunit"
- ],
- "test:wp-env": [
- "wp-env run tests-cli --env-cwd=\"wp-content/plugins/activitypub\" vendor/bin/phpunit"
- ],
- "lint": [
- "vendor/bin/phpcs"
- ],
- "lint:fix": [
- "vendor/bin/phpcbf"
- ],
- "changelog:add": [
- "composer install",
- "vendor/bin/changelogger add"
- ],
- "changelog:write": [
- "composer install",
- "vendor/bin/changelogger write --add-pr-num"
- ]
- }
+ "name": "pfefferle/wordpress-activitypub",
+ "description": "The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.",
+ "type": "wordpress-plugin",
+ "require": {
+ "php": ">=7.2",
+ "composer/installers": "^1.0 || ^2.0"
+ },
+ "require-dev": {
+ "automattic/jetpack-changelogger": "6.0.0",
+ "phpunit/phpunit": "^8 || ^9",
+ "phpcompatibility/php-compatibility": "*",
+ "phpcompatibility/phpcompatibility-wp": "*",
+ "squizlabs/php_codesniffer": "3.*",
+ "wp-coding-standards/wpcs": "dev-develop",
+ "yoast/phpunit-polyfills": "^4.0",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
+ "sirbrillig/phpcs-variable-analysis": "^2.11",
+ "phpcsstandards/phpcsextra": "^1.1.0",
+ "dms/phpunit-arraysubset-asserts": "^0.5.0"
+ },
+ "config": {
+ "allow-plugins": true
+ },
+ "allow-plugins": {
+ "composer/installers": true
+ },
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Matthias Pfefferle",
+ "email": "pfefferle@gmail.com"
+ }
+ ],
+ "extra": {
+ "installer-name": "activitypub",
+ "changelogger": {
+ "changes-dir": ".github/changelog/",
+ "link-template": "https://github.com/Automattic/wordpress-activitypub/compare/${old}...${new}"
+ }
+ },
+ "scripts": {
+ "test": [
+ "composer install",
+ "bin/install-wp-tests.sh activitypub-test root activitypub-test test-db latest true",
+ "vendor/bin/phpunit"
+ ],
+ "test:wp-env": [
+ "wp-env run tests-cli --env-cwd=\"wp-content/plugins/activitypub\" vendor/bin/phpunit"
+ ],
+ "lint": [
+ "vendor/bin/phpcs"
+ ],
+ "lint:fix": [
+ "vendor/bin/phpcbf"
+ ],
+ "changelog:add": [
+ "composer install",
+ "vendor/bin/changelogger add"
+ ],
+ "changelog:write": [
+ "composer install",
+ "vendor/bin/changelogger write --add-pr-num"
+ ]
+ }
}
diff --git a/docs/developer-docs.md b/docs/developer-docs.md
index 76543bb7f..767e84fec 100644
--- a/docs/developer-docs.md
+++ b/docs/developer-docs.md
@@ -3,6 +3,12 @@
## Table of Contents
- [Introduction](#introduction)
- [Extending the Settings Interface](#extending-the-settings-interface)
+- [JavaScript and CSS Development](#javascript-and-css-development)
+ - [Block Development](#block-development)
+ - [Feature-Based Asset Organization](#feature-based-asset-organization)
+- [Development Workflow](#development-workflow)
+ - [Available Commands](#available-commands)
+ - [Build Process](#build-process)
## Introduction
This documentation provides information for developers who want to extend and build upon the ActivityPub plugin. Whether you're developing a complementary plugin or integrating ActivityPub features into your existing WordPress plugin, this guide will help you understand the available hooks and customization options.
@@ -59,3 +65,170 @@ add_action( 'admin_enqueue_scripts', function( $hook ) {
}
} );
```
+
+## JavaScript and CSS Development
+
+### Block Development
+
+The ActivityPub plugin uses the WordPress Block Editor (Gutenberg) architecture for developing custom blocks. All block-related code is located in the `/src/blocks` directory.
+
+#### Block Structure
+
+Each block typically follows this directory structure:
+
+```
+/src/blocks/block-name/
+├── block.json # Block configuration.
+├── edit.js # Edit component.
+├── index.js # Block registration.
+├── style.scss # Block styles.
+└── view.js # Frontend JavaScript (optional).
+```
+
+#### Best Practices for Block Development
+
+1. **Follow WordPress Coding Standards**: Adhere to WordPress JavaScript and CSS coding standards.
+2. **Use WordPress Components**: Leverage existing WordPress components from `@wordpress/components`.
+3. **Internationalization**: Make all user-facing strings translatable using the `__()` function.
+4. **Accessibility**: Ensure your blocks are accessible to all users.
+5. **Indentation**: Use tabs for indentation in SCSS files, not spaces.
+
+### Feature-Based Asset Organization
+
+The ActivityPub plugin organizes scripts and styles by feature rather than by file type. Each feature has its own directory containing all related assets.
+
+#### Structure
+
+```
+/src/
+├── blocks/ # Block-specific code and styles
+│ ├── reply/ # Reply block
+│ └── ... # Other blocks
+├── wp-admin/ # Admin-related features
+│ ├── admin.js # Admin JavaScript
+│ ├── admin.scss # Admin styles
+│ └── ... # Other admin features
+├── feature-name/ # Any other feature
+│ ├── script.js # Feature JavaScript
+│ ├── style.scss # Feature styles
+│ └── ... # Other feature files
+└── ... # Other feature directories
+
+/build/ # Compiled assets, organized by feature
+```
+
+#### Adding New Feature Assets
+
+1. Create a new directory for your feature in the `/src/` directory:
+ ```
+ /src/your-feature/
+ ```
+
+2. Add your JavaScript and/or SCSS files to this directory:
+ ```
+ /src/your-feature/script.js
+ /src/your-feature/style.scss
+ ```
+
+3. When you run `npm run build`, WordPress Scripts will:
+ - Compile your JavaScript file to `/build/your-feature/script.js`.
+ - Compile and minify your SCSS to `/build/your-feature/style.css`.
+ - Generate source maps.
+
+4. Enqueue your script and/or stylesheet in PHP, using the generated asset file:
+
+```php
+/**
+ * Enqueue admin scripts.
+ */
+function activitypub_enqueue_admin_scripts() {
+ // Load the asset file to get dependencies and version.
+ $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/your-feature/script.asset.php';
+
+ wp_enqueue_script(
+ 'activitypub-your-feature',
+ plugins_url(
+ 'build/your-feature/script.js',
+ ACTIVITYPUB_PLUGIN_FILE
+ ),
+ $asset_data['dependencies'],
+ $asset_data['version'],
+ true
+ );
+}
+add_action( 'admin_enqueue_scripts', 'activitypub_enqueue_admin_scripts' );
+
+/**
+ * Enqueue admin styles.
+ */
+function activitypub_enqueue_admin_styles() {
+ wp_enqueue_style(
+ 'activitypub-your-feature',
+ plugins_url(
+ 'build/your-feature/style.css',
+ ACTIVITYPUB_PLUGIN_FILE
+ ),
+ array(),
+ ACTIVITYPUB_PLUGIN_VERSION
+ );
+
+ // Add RTL support.
+ wp_style_add_data( 'activitypub-your-feature', 'rtl', 'replace' );
+}
+add_action( 'admin_enqueue_scripts', 'activitypub_enqueue_admin_styles' );
+```
+
+## Development Workflow
+
+The ActivityPub plugin uses WordPress Scripts (`@wordpress/scripts`) for development, building, and linting JavaScript and CSS files.
+
+### Available Commands
+
+The following npm scripts are available in `package.json`:
+
+- `npm run dev`: Start the development server with hot reloading.
+- `npm run build`: Format code and build production assets.
+- `npm run format`: Format JavaScript files using Prettier. Also part of the build process.
+- `npm run lint:css`: Lint CSS/SCSS files.
+- `npm run lint:js`: Lint JavaScript files.
+- `npm run env`: Run WordPress environment commands.
+- `npm run env-start`: Start the WordPress development environment.
+- `npm run env-stop`: Stop the WordPress development environment.
+- `npm run env-test`: Run PHPUnit tests in the WordPress environment.
+- `npm run release`: Create a new release.
+
+### Build Process
+
+#### Development
+
+During development, use the following workflow:
+
+1. Start the development server:
+ ```
+ npm run dev
+ ```
+ This will watch for changes in your JavaScript and SCSS files and automatically rebuild them.
+
+2. Make your changes to the source files in `/src` or `/assets`.
+
+3. Test your changes in the browser.
+
+#### Production
+
+Before committing and pushing your changes to the remote repository:
+
+1. Build the production assets:
+ ```
+ npm run build
+ ```
+ This will:
+ - Format your JavaScript files.
+ - Compile and minify JavaScript.
+ - Compile and minify SCSS to CSS.
+ - Generate source maps.
+
+2. Commit both your source files and the built assets.
+
+3. Push your changes to the remote repository.
+
+> **Important**: Always run `npm run build` before pushing your changes to ensure that the built assets are up-to-date with your source code.
diff --git a/docs/how-to/readme.md b/docs/how-to/readme.md
new file mode 100644
index 000000000..004387c51
--- /dev/null
+++ b/docs/how-to/readme.md
@@ -0,0 +1,3 @@
+# How-To
+
+This folder contains How-To guides for common problems or edge cases. If you miss a guide, please [open an issue](https://github.com/Automattic/wordpress-activitypub/issues/new/choose) or [submit a pull request](https://github.com/Automattic/wordpress-activitypub/pulls).
diff --git a/docs/how-to/reverse-proxy.md b/docs/how-to/reverse-proxy.md
new file mode 100644
index 000000000..c97036e68
--- /dev/null
+++ b/docs/how-to/reverse-proxy.md
@@ -0,0 +1,17 @@
+# Handling reverse proxy setups with Apache
+
+If you are using a reverse proxy with Apache to serve your site, you may find that followers are unable to follow your blog. This happens because the proxy rewrites the `Host` header to your server’s internal DNS name, which the plugin then uses to sign replies. However, remote servers expect replies to be signed with your public DNS name. To resolve this, you need to use the `ProxyPreserveHost On` directive to ensure that the external host name is passed through to the backend server.
+
+If you are using SSL between the reverse proxy and the internal host, you may also need to set `SSLProxyCheckPeerName off` if the internal host does not present the correct SSL certificate. Be aware that this can introduce a security risk in some environments.
+
+## Example
+
+```apache
+
+ ServerName example.com
+ ProxyPreserveHost On
+ SSLProxyCheckPeerName off
+ ProxyPass / http://localhost:8080/
+ ProxyPassReverse / http://localhost:8080/
+
+```
diff --git a/docs/how-to/wordpress-in-a-subdir.md b/docs/how-to/wordpress-in-a-subdir.md
new file mode 100644
index 000000000..567f674c8
--- /dev/null
+++ b/docs/how-to/wordpress-in-a-subdir.md
@@ -0,0 +1,24 @@
+# Using a subdirectory for your blog
+
+For WebFinger to function properly, it needs to be mapped to the root directory of your blog’s URL.
+
+## Apache
+
+Add the following lines to the `.htaccess` file located in your site's root directory:
+
+ RedirectMatch "^\/\.well-known/(webfinger|nodeinfo)(.*)$" /blog/.well-known/$1$2
+
+…where `blog` is the path to the subdirectory where your blog is installed.
+
+## Nginx
+
+Add the following lines to your `site.conf` file in the `sites-available` directory:
+
+ location ~* /.well-known {
+ allow all;
+ try_files $uri $uri/ /blog/?$args;
+ }
+
+Where `blog` is the path to the subdirectory where your blog is installed.
+
+If your blog is installed in a subdirectory but you’ve set a different [wp_siteurl](https://wordpress.org/documentation/article/giving-wordpress-its-own-directory/), you don’t need the redirect — index.php will handle it automatically.
diff --git a/docs/readme.md b/docs/readme.md
index 261bbefd3..2319460b1 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -1 +1,8 @@
# The ActivityPub plugin documentation!
+
+In this directory you will find the documentation for the ActivityPub plugin.
+
+## How-To
+
+If you need help, check out the [How-To section](./how-to) or use [the support forums on WordPress.org](https://wordpress.org/support/plugin/activitypub/).
+
diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php
index 7796a6a0f..4724888af 100644
--- a/includes/activity/class-actor.php
+++ b/includes/activity/class-actor.php
@@ -23,6 +23,7 @@ class Actor extends Base_Object {
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
'https://purl.archive.org/socialweb/webfinger',
+ 'https://w3id.org/fep/844e',
array(
'schema' => 'http://schema.org#',
'toot' => 'http://joinmastodon.org/ns#',
@@ -221,4 +222,90 @@ class Actor extends Base_Object {
* @var array
*/
protected $also_known_as;
+
+ /**
+ * The Featured-Posts.
+ *
+ * @see https://docs.joinmastodon.org/spec/activitypub/#featured
+ *
+ * @context {
+ * "@id": "http://joinmastodon.org/ns#featured",
+ * "@type": "@id"
+ * }
+ *
+ * @var string
+ */
+ protected $featured;
+
+ /**
+ * The Featured-Tags.
+ *
+ * @see https://docs.joinmastodon.org/spec/activitypub/#featuredTags
+ *
+ * @context {
+ * "@id": "http://joinmastodon.org/ns#featuredTags",
+ * "@type": "@id"
+ * }
+ *
+ * @var string
+ */
+ protected $featured_tags;
+
+ /**
+ * Whether the User is discoverable.
+ *
+ * @see https://docs.joinmastodon.org/spec/activitypub/#discoverable
+ *
+ * @context http://joinmastodon.org/ns#discoverable
+ *
+ * @var boolean
+ */
+ protected $discoverable;
+
+ /**
+ * Whether the User is indexable.
+ *
+ * @see https://docs.joinmastodon.org/spec/activitypub/#indexable
+ *
+ * @context http://joinmastodon.org/ns#indexable
+ *
+ * @var boolean
+ */
+ protected $indexable;
+
+ /**
+ * The WebFinger Resource.
+ *
+ * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md
+ *
+ * @var string
+ */
+ protected $webfinger;
+
+ /**
+ * URL to the Moderators endpoint.
+ *
+ * @see https://join-lemmy.org/docs/contributors/05-federation.html
+ *
+ * @var string
+ */
+ protected $moderators;
+
+ /**
+ * Restrict posting to mods.
+ *
+ * @see https://join-lemmy.org/docs/contributors/05-federation.html
+ *
+ * @var boolean
+ */
+ protected $posting_restricted_to_mods;
+
+ /**
+ * Listing Implemented Specifications on the Application Actor
+ *
+ * @see https://codeberg.org/helge/fep/src/commit/e1b2a16707b542ea5ea0cfb390ac1abce89f05bb/fep/aaa3/fep-aaa3.md
+ *
+ * @var array
+ */
+ protected $implemented;
}
diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php
index 508d24da6..8a96ab964 100644
--- a/includes/activity/class-base-object.php
+++ b/includes/activity/class-base-object.php
@@ -32,6 +32,7 @@ class Base_Object extends Generic_Object {
array(
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
+ 'dcterms' => 'http://purl.org/dc/terms/',
),
);
@@ -416,6 +417,16 @@ class Base_Object extends Generic_Object {
*/
protected $sensitive;
+ /**
+ * The dcterms namespace.
+ *
+ * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md#sensitive
+ * @see https://www.dublincore.org/specifications/dublin-core/dcmi-terms/
+ *
+ * @var array
+ */
+ protected $dcterms;
+
/**
* Generic getter.
*
diff --git a/includes/activity/class-generic-object.php b/includes/activity/class-generic-object.php
index 3dddcea0e..bdb3c6698 100644
--- a/includes/activity/class-generic-object.php
+++ b/includes/activity/class-generic-object.php
@@ -19,25 +19,28 @@
*
* @since 5.3.0
*
- * @method string|null get_actor() Gets one or more entities that performed or are expected to perform the activity.
- * @method string[]|null get_also_known_as() Gets the also known as property of the object.
- * @method string|null get_attributed_to() Gets the entity attributed as the original author.
- * @method array[]|null get_attachment() Gets the attachment property of the object.
- * @method string[]|null get_cc() Gets the secondary recipients of the object.
- * @method string|null get_content() Gets the content property of the object.
- * @method string[]|null get_icon() Gets the icon property of the object.
- * @method string|null get_id() Gets the object's unique global identifier.
- * @method string[]|null get_image() Gets the image property of the object.
- * @method string[]|string|null get_in_reply_to() Gets the objects this object is in reply to.
- * @method string|null get_name() Gets the natural language name of the object.
- * @method Base_Object|string|null get_object() Gets the direct object of the activity.
- * @method string|null get_published() Gets the date and time the object was published in ISO 8601 format.
- * @method string|null get_summary() Gets the natural language summary of the object.
- * @method array[]|null get_tag() Gets the tag property of the object.
- * @method string[]|string|null get_to() Gets the primary recipients of the object.
- * @method string get_type() Gets the type of the object.
- * @method string|null get_updated() Gets the date and time the object was updated in ISO 8601 format.
- * @method string|null get_url() Gets the URL of the object.
+ * @method string|null get_actor() Gets one or more entities that performed or are expected to perform the activity.
+ * @method string[]|null get_also_known_as() Gets the also known as property of the object.
+ * @method string|null get_attributed_to() Gets the entity attributed as the original author.
+ * @method array[]|null get_attachment() Gets the attachment property of the object.
+ * @method string[]|null get_cc() Gets the secondary recipients of the object.
+ * @method string|null get_content() Gets the content property of the object.
+ * @method string[]|null get_endpoints() Gets the endpoint property of the object.
+ * @method string[]|null get_icon() Gets the icon property of the object.
+ * @method string|null get_id() Gets the object's unique global identifier.
+ * @method string[]|null get_image() Gets the image property of the object.
+ * @method string[]|string|null get_in_reply_to() Gets the objects this object is in reply to.
+ * @method string|null get_inbox() Gets the inbox property of the object.
+ * @method string|null get_name() Gets the natural language name of the object.
+ * @method Base_Object|string|null get_object() Gets the direct object of the activity.
+ * @method string|null get_preferred_username() Gets the preferred username of the object.
+ * @method string|null get_published() Gets the date and time the object was published in ISO 8601 format.
+ * @method string|null get_summary() Gets the natural language summary of the object.
+ * @method array[]|null get_tag() Gets the tag property of the object.
+ * @method string[]|string|null get_to() Gets the primary recipients of the object.
+ * @method string get_type() Gets the type of the object.
+ * @method string|null get_updated() Gets the date and time the object was updated in ISO 8601 format.
+ * @method string|null get_url() Gets the URL of the object.
*
* @method string|string[] add_cc( string|array $cc ) Adds one or more entities to the secondary audience of the object.
* @method string|string[] add_to( string|array $to ) Adds one or more entities to the primary audience of the object.
@@ -294,8 +297,11 @@ public function to_array( $include_json_ld_context = true ) {
$value = $value->to_array( false );
}
- // If value is still empty, ignore it for the array and continue.
- if ( isset( $value ) ) {
+ if ( is_array( $value ) && $this->is_namespaced( $key ) ) {
+ foreach ( $value as $sub_key => $sub_value ) {
+ $array[ snake_to_camel_case( $key ) . ':' . snake_to_camel_case( $sub_key ) ] = $sub_value;
+ }
+ } elseif ( isset( $value ) ) {
$array[ snake_to_camel_case( $key ) ] = $value;
}
}
@@ -370,4 +376,23 @@ public function get_object_var_keys() {
public function get_json_ld_context() {
return static::JSON_LD_CONTEXT;
}
+
+ /**
+ * Checks if an attribute is in a namespace.
+ *
+ * @param string $attribute The attribute to check.
+ *
+ * @return bool Whether the attribute is namespaced.
+ */
+ private function is_namespaced( $attribute ) {
+ $namespaces = array();
+
+ foreach ( static::JSON_LD_CONTEXT as $context ) {
+ if ( is_array( $context ) ) {
+ $namespaces = \array_merge( $namespaces, $context );
+ }
+ }
+
+ return isset( $namespaces[ $attribute ] ) && \wp_http_validate_url( $namespaces[ $attribute ] );
+ }
}
diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php
index 9d0343948..20367363e 100644
--- a/includes/class-activitypub.php
+++ b/includes/class-activitypub.php
@@ -48,8 +48,9 @@ public static function init() {
\add_filter( 'activitypub_get_actor_extra_fields', array( Extra_Fields::class, 'default_actor_extra_fields' ), 10, 2 );
- \add_action( 'updated_postmeta', array( self::class, 'updated_postmeta' ), 10, 4 );
- \add_action( 'added_post_meta', array( self::class, 'updated_postmeta' ), 10, 4 );
+ \add_filter( 'add_post_metadata', array( self::class, 'prevent_empty_post_meta' ), 10, 4 );
+ \add_filter( 'update_post_metadata', array( self::class, 'prevent_empty_post_meta' ), 10, 4 );
+ \add_filter( 'default_post_metadata', array( self::class, 'default_post_metadata' ), 10, 3 );
\add_action( 'init', array( self::class, 'register_user_meta' ), 11 );
@@ -309,6 +310,7 @@ public static function add_query_vars( $vars ) {
$vars[] = 'preview';
$vars[] = 'author';
$vars[] = 'actor';
+ $vars[] = 'type';
$vars[] = 'c';
$vars[] = 'p';
@@ -332,18 +334,14 @@ public static function pre_get_avatar_data( $args, $id_or_email ) {
return $args;
}
+ /**
+ * Filter allowed comment types for avatars.
+ *
+ * @param array $allowed_comment_types Array of allowed comment types.
+ */
$allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) );
- if (
- ! empty( $id_or_email->comment_type ) &&
- ! \in_array(
- $id_or_email->comment_type,
- (array) $allowed_comment_types,
- true
- )
- ) {
- $args['url'] = false;
- /** This filter is documented in wp-includes/link-template.php */
- return \apply_filters( 'get_avatar_data', $args, $id_or_email );
+ if ( ! \in_array( $id_or_email->comment_type ?: 'comment', $allowed_comment_types, true ) ) { // phpcs:ignore Universal.Operators.DisallowShortTernary
+ return $args;
}
// Check if comment has an avatar.
@@ -356,8 +354,12 @@ public static function pre_get_avatar_data( $args, $id_or_email ) {
$args['class'] = \explode( ' ', $args['class'] );
}
- $args['url'] = $avatar;
+ /** This filter is documented in wp-includes/link-template.php */
+ $args['url'] = \apply_filters( 'get_avatar_url', $avatar, $id_or_email, $args );
+ $args['class'][] = 'avatar';
$args['class'][] = 'avatar-activitypub';
+ $args['class'][] = 'avatar-' . (int) $args['size'];
+ $args['class'][] = 'photo';
$args['class'][] = 'u-photo';
$args['class'] = \array_unique( $args['class'] );
}
@@ -455,7 +457,7 @@ public static function theme_compat() {
*/
private static function register_post_types() {
\register_post_type(
- Followers::POST_TYPE,
+ Actors::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Followers', 'post_type plural name', 'activitypub' ),
@@ -472,7 +474,7 @@ private static function register_post_types() {
);
\register_post_meta(
- Followers::POST_TYPE,
+ Actors::POST_TYPE,
'_activitypub_inbox',
array(
'type' => 'string',
@@ -482,7 +484,7 @@ private static function register_post_types() {
);
\register_post_meta(
- Followers::POST_TYPE,
+ Actors::POST_TYPE,
'_activitypub_errors',
array(
'type' => 'string',
@@ -498,8 +500,8 @@ private static function register_post_types() {
);
\register_post_meta(
- Followers::POST_TYPE,
- '_activitypub_user_id',
+ Actors::POST_TYPE,
+ Followers::FOLLOWER_META_KEY,
array(
'type' => 'string',
'single' => false,
@@ -509,18 +511,6 @@ private static function register_post_types() {
)
);
- \register_post_meta(
- Followers::POST_TYPE,
- '_activitypub_actor_json',
- array(
- 'type' => 'string',
- 'single' => true,
- 'sanitize_callback' => function ( $value ) {
- return sanitize_text_field( $value );
- },
- )
- );
-
// Register Outbox Post-Type.
register_post_type(
Outbox::POST_TYPE,
@@ -692,18 +682,58 @@ public static function user_register( $user_id ) {
}
/**
- * Delete `activitypub_content_visibility` when updated to an empty value.
+ * Prevent empty or default meta values.
*
- * @param int $meta_id ID of updated metadata entry.
- * @param int $object_id Post ID.
+ * @param null|bool $check Whether to allow updating metadata for the given type.
+ * @param int $object_id ID of the object metadata is for.
+ * @param string $meta_key Metadata key.
+ * @param mixed $meta_value Metadata value. Must be serializable if non-scalar.
+ */
+ public static function prevent_empty_post_meta( $check, $object_id, $meta_key, $meta_value ) {
+ $post_metas = array(
+ 'activitypub_content_visibility' => '',
+ 'activitypub_content_warning' => '',
+ 'activitypub_max_image_attachments' => (string) \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ),
+ );
+
+ if ( isset( $post_metas[ $meta_key ] ) && $post_metas[ $meta_key ] === (string) $meta_value ) {
+ if ( 'update_post_metadata' === current_action() ) {
+ \delete_post_meta( $object_id, $meta_key );
+ }
+
+ $check = true;
+ }
+
+ return $check;
+ }
+
+ /**
+ * Adjusts default post meta values.
+ *
+ * @param mixed $meta_value The meta value.
+ * @param int $object_id ID of the object metadata is for.
* @param string $meta_key Metadata key.
- * @param mixed $meta_value Metadata value. This will be a PHP-serialized string representation of the value
- * if the value is an array, an object, or itself a PHP-serialized string.
+ *
+ * @return mixed The meta value.
*/
- public static function updated_postmeta( $meta_id, $object_id, $meta_key, $meta_value ) {
- if ( 'activitypub_content_visibility' === $meta_key && empty( $meta_value ) ) {
- \delete_post_meta( $object_id, 'activitypub_content_visibility' );
+ public static function default_post_metadata( $meta_value, $object_id, $meta_key ) {
+ // Check if the meta key is `activitypub_content_visibility`.
+ if ( 'activitypub_content_visibility' !== $meta_key ) {
+ return $meta_value;
+ }
+
+ // If the post is federated, return the default visibility.
+ if ( 'federated' === \get_post_meta( $object_id, 'activitypub_status', true ) ) {
+ return $meta_value;
+ }
+
+ // If the post is not federated and older than a year, return local visibility.
+ $date = \get_the_date( 'U', $object_id );
+ if ( $date < \strtotime( '-1 month' ) ) {
+ return ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL;
}
+
+ return $meta_value;
}
/**
@@ -732,7 +762,7 @@ public static function register_user_meta() {
'description' => 'An array of URLs that the user is known by.',
'single' => true,
'default' => array(),
- 'sanitize_callback' => array( Sanitize::class, 'url_list' ),
+ 'sanitize_callback' => array( Sanitize::class, 'identifier_list' ),
)
);
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 83060b8e0..7c01bbe1f 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -8,7 +8,6 @@
namespace Activitypub;
use Activitypub\Collection\Actors;
-use Activitypub\Collection\Followers;
/**
* Block class.
@@ -21,12 +20,11 @@ public static function init() {
// This is already being called on the init hook, so just add it.
self::register_blocks();
- \add_action( 'wp_head', array( self::class, 'inject_activitypub_options' ), 11 );
- \add_action( 'admin_print_scripts', array( self::class, 'inject_activitypub_options' ) );
\add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
// Add editor plugin.
\add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
\add_action( 'init', array( self::class, 'register_postmeta' ), 11 );
+ \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
\add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
}
@@ -44,13 +42,7 @@ public static function register_postmeta() {
'show_in_rest' => true,
'single' => true,
'type' => 'string',
- 'sanitize_callback' => function ( $warning ) {
- if ( $warning ) {
- return \sanitize_text_field( $warning );
- }
-
- return null;
- },
+ 'sanitize_callback' => 'sanitize_text_field',
)
);
@@ -95,14 +87,25 @@ public static function register_postmeta() {
* Enqueue the block editor assets.
*/
public static function enqueue_editor_assets() {
+ $data = array(
+ 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
+ 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
+ 'enabled' => array(
+ 'blog' => ! is_user_type_disabled( 'blog' ),
+ 'users' => ! is_user_type_disabled( 'user' ),
+ ),
+ );
+ wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
+
// Check for our supported post types.
$current_screen = \get_current_screen();
$ap_post_types = \get_post_types_by_support( 'activitypub' );
if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
return;
}
- $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
- $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
+
+ $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/blocks/editor-plugin/plugin.asset.php';
+ $plugin_url = plugins_url( 'build/blocks/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
@@ -116,100 +119,69 @@ public static function handle_in_reply_to_get_param() {
return;
}
- $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
- $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
+ $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/blocks/reply-intent/plugin.asset.php';
+ $plugin_url = plugins_url( 'build/blocks/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
- /**
- * Output ActivityPub options as a script tag.
- */
- public static function inject_activitypub_options() {
- $data = array(
- 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
- 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
- 'enabled' => array(
- 'site' => ! is_user_type_disabled( 'blog' ),
- 'users' => ! is_user_type_disabled( 'user' ),
- ),
- 'maxImageAttachments' => \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ),
- );
-
- printf(
- "\n",
- wp_json_encode( $data )
- );
- }
-
/**
* Register the blocks.
*/
public static function register_blocks() {
- \register_block_type_from_metadata(
- ACTIVITYPUB_PLUGIN_DIR . '/build/followers',
- array(
- 'render_callback' => array( self::class, 'render_follower_block' ),
- )
- );
- \register_block_type_from_metadata(
- ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me',
- array(
- 'render_callback' => array( self::class, 'render_follow_me_block' ),
- )
- );
- \register_block_type_from_metadata(
- ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
- array(
- 'render_callback' => array( self::class, 'render_reply_block' ),
- )
- );
+ \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/blocks/follow-me' );
+ \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/blocks/followers' );
+ \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/blocks/reactions' );
\register_block_type_from_metadata(
- ACTIVITYPUB_PLUGIN_DIR . '/build/reactions',
+ ACTIVITYPUB_PLUGIN_DIR . '/build/blocks/reply',
array(
- 'render_callback' => array( self::class, 'render_post_reactions_block' ),
+ 'render_callback' => array( self::class, 'render_reply_block' ),
)
);
}
/**
- * Render the post reactions block.
- *
- * @param array $attrs The block attributes.
- * @param string $content Inner blocks.
- *
- * @return string The HTML to render.
+ * Register REST fields needed for blocks.
*/
- public static function render_post_reactions_block( $attrs, $content ) {
- if ( ! isset( $attrs['postId'] ) ) {
- $attrs['postId'] = get_the_ID();
- }
-
- $args = array( 'data-attrs' => wp_json_encode( $attrs ) );
- if ( isset( $attrs['title'] ) ) {
- $args['class'] = 'activitypub-reactions-block';
- }
-
- return sprintf(
- '%2$s
',
- get_block_wrapper_attributes( $args ),
- $content
+ public static function register_rest_fields() {
+ // Register the post_count field for Follow Me block.
+ register_rest_field(
+ 'user',
+ 'post_count',
+ array(
+ /**
+ * Get the number of published posts.
+ *
+ * @param array $response Prepared response array.
+ * @param string $field_name The field name.
+ * @param \WP_REST_Request $request The request object.
+ * @return int The number of published posts.
+ */
+ 'get_callback' => function ( $response, $field_name, $request ) {
+ return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
+ },
+ 'schema' => array(
+ 'description' => 'Number of published posts',
+ 'type' => 'integer',
+ 'context' => array( 'activitypub' ),
+ ),
+ )
);
}
/**
* Get the user ID from a user string.
*
- * @param string $user_string The user string. Can be a user ID, 'site', or 'inherit'.
+ * @param string $user_string The user string. Can be a user ID, 'blog', or 'inherit'.
* @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
*/
- private static function get_user_id( $user_string ) {
+ public static function get_user_id( $user_string ) {
if ( is_numeric( $user_string ) ) {
return absint( $user_string );
}
- // If the user string is 'site', return the Blog User ID.
- if ( 'site' === $user_string ) {
+ // If the user string is 'blog', return the Blog User ID.
+ if ( 'blog' === $user_string ) {
return Actors::BLOG_USER_ID;
}
@@ -249,103 +221,6 @@ private static function get_user_id( $user_string ) {
return null;
}
- /**
- * Filter an array by a list of keys.
- *
- * @param array $data The array to filter.
- * @param array $keys The keys to keep.
- * @return array The filtered array.
- */
- protected static function filter_array_by_keys( $data, $keys ) {
- return array_intersect_key( $data, array_flip( $keys ) );
- }
-
- /**
- * Render the follow me block.
- *
- * @param array $attrs The block attributes.
- * @return string The HTML to render.
- */
- public static function render_follow_me_block( $attrs ) {
- $user_id = self::get_user_id( $attrs['selectedUser'] );
- $user = Actors::get_by_id( $user_id );
- if ( is_wp_error( $user ) ) {
- if ( 'inherit' === $attrs['selectedUser'] ) {
- // If the user is 'inherit' and we couldn't determine the user, don't render anything.
- return '';
- } else {
- // If the user is a specific ID and we couldn't find it, render an error message.
- return '';
- }
- }
-
- $attrs['profileData'] = self::filter_array_by_keys(
- $user->to_array(),
- array( 'icon', 'name', 'webfinger' )
- );
-
- $wrapper_attributes = get_block_wrapper_attributes(
- array(
- 'class' => 'activitypub-follow-me-block-wrapper',
- 'data-attrs' => wp_json_encode( $attrs ),
- )
- );
- // todo: render more than an empty div?
- return '
';
- }
-
- /**
- * Render the follower block.
- *
- * @param array $attrs The block attributes.
- *
- * @return string The HTML to render.
- */
- public static function render_follower_block( $attrs ) {
- $followee_user_id = self::get_user_id( $attrs['selectedUser'] );
- if ( is_null( $followee_user_id ) ) {
- return '';
- }
-
- $user = Actors::get_by_id( $followee_user_id );
- if ( is_wp_error( $user ) ) {
- return '';
- }
-
- $per_page = absint( $attrs['per_page'] );
- $follower_data = Followers::get_followers_with_count( $followee_user_id, $per_page );
-
- $attrs['followerData']['total'] = $follower_data['total'];
- $attrs['followerData']['followers'] = array_map(
- function ( $follower ) {
- return self::filter_array_by_keys(
- $follower->to_array(),
- array( 'icon', 'name', 'preferredUsername', 'url' )
- );
- },
- $follower_data['followers']
- );
- $wrapper_attributes = get_block_wrapper_attributes(
- array(
- 'aria-label' => __( 'Fediverse Followers', 'activitypub' ),
- 'class' => 'activitypub-follower-block',
- 'data-attrs' => wp_json_encode( $attrs ),
- )
- );
-
- $html = '';
- if ( $attrs['title'] ) {
- $html .= '
' . esc_html( $attrs['title'] ) . ' ';
- }
- $html .= '
';
- foreach ( $follower_data['followers'] as $follower ) {
- $html .= '' . self::render_follower( $follower ) . ' ';
- }
- // We are only pagination on the JS side. Could be revisited but we gotta ship!
- $html .= ' ';
- return $html;
- }
-
/**
* Render the reply block.
*
@@ -396,36 +271,50 @@ public static function render_reply_block( $attrs ) {
}
/**
- * Render a follower.
- *
- * @param \Activitypub\Model\Follower $follower The follower to render.
+ * Renders a modal component that can be used by different blocks.
*
- * @return string The HTML to render.
+ * @param array $args Arguments for the modal.
*/
- public static function render_follower( $follower ) {
- $external_svg = ' ';
- $template =
- '
-
-
- %s
- /
- @%s
-
- %s
- ';
-
- $data = $follower->to_array();
-
- return sprintf(
- $template,
- esc_url( object_to_uri( $data['url'] ) ),
- esc_attr( $data['name'] ),
- esc_attr( $data['icon']['url'] ),
- esc_html( $data['name'] ),
- esc_html( $data['preferredUsername'] ),
- $external_svg
+ public static function render_modal( $args = array() ) {
+ $defaults = array(
+ 'content' => '',
+ 'is_compact' => false,
+ 'title' => '',
);
+
+ $args = \wp_parse_args( $args, $defaults );
+ ?>
+
+
+ next_tag( $selector ) ) {
+ foreach ( $attributes as $key => $value ) {
+ if ( 'class' === $key ) {
+ $tags->add_class( $value );
+ continue;
+ }
+
+ $tags->set_attribute( $key, $value );
+ }
+ }
+
+ return $tags->get_updated_html();
+ }
}
diff --git a/includes/class-cli.php b/includes/class-cli.php
index b5c9d224d..a9db67ca4 100644
--- a/includes/class-cli.php
+++ b/includes/class-cli.php
@@ -227,4 +227,32 @@ public function move( $args ) {
\WP_CLI::success( 'Move Scheduled.' );
}
}
+
+ /**
+ * Follow a user.
+ *
+ * ## OPTIONS
+ *
+ *
+ * The remote user to follow.
+ *
+ * ## EXAMPLES
+ *
+ * $ wp activitypub follow https://example.com/@user
+ * $ wp --user=pfefferle activitypub follow https://example.com/@user
+ *
+ * @synopsis
+ *
+ * @param array $args The arguments.
+ */
+ public function follow( $args ) {
+ $user_id = \get_current_user_id();
+ $follow = follow( $args[0], $user_id );
+
+ if ( is_wp_error( $follow ) ) {
+ \WP_CLI::error( $follow->get_error_message() );
+ } else {
+ \WP_CLI::success( 'Follow Scheduled.' );
+ }
+ }
}
diff --git a/includes/class-comment.php b/includes/class-comment.php
index 58268ed2c..469a7b9a2 100644
--- a/includes/class-comment.php
+++ b/includes/class-comment.php
@@ -23,10 +23,11 @@ class Comment {
public static function init() {
self::register_comment_types();
+ \add_filter( 'map_meta_cap', array( self::class, 'map_meta_cap' ), 10, 4 );
\add_filter( 'comment_reply_link', array( self::class, 'comment_reply_link' ), 10, 3 );
\add_filter( 'comment_class', array( self::class, 'comment_class' ), 10, 3 );
+ \add_filter( 'comment_feed_where', array( static::class, 'comment_feed_where' ) );
\add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 2 );
- \add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
\add_action( 'pre_get_comments', array( static::class, 'comment_query' ) );
\add_filter( 'pre_comment_approved', array( static::class, 'pre_comment_approved' ), 10, 2 );
\add_filter( 'get_avatar_comment_types', array( static::class, 'get_avatar_comment_types' ), 99 );
@@ -35,6 +36,26 @@ public static function init() {
\add_filter( 'pre_wp_update_comment_count_now', array( static::class, 'pre_wp_update_comment_count_now' ), 10, 3 );
}
+ /**
+ * Remove edit capabilities for comments received via ActivityPub.
+ *
+ * @param array $caps Array of capabilities.
+ * @param string $cap Capability name.
+ * @param int $user_id User ID.
+ * @param array $args Array of arguments.
+ *
+ * @return array Modified array of capabilities.
+ */
+ public static function map_meta_cap( $caps, $cap, $user_id, $args ) {
+ if ( 'edit_comment' === $cap && self::was_received( $args[0] ) ) {
+ if ( ! \is_admin() || ( isset( $GLOBALS['current_screen'] ) && 'comment' === $GLOBALS['current_screen']->id ) ) {
+ $caps[] = 'do_not_allow';
+ }
+ }
+
+ return $caps;
+ }
+
/**
* Filter the comment reply link.
*
@@ -56,24 +77,23 @@ public static function comment_reply_link( $link, $args, $comment ) {
return $link;
}
- $attrs = array(
+ if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'activitypub/remote-reply' ) ) {
+ \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply' );
+ }
+
+ $attributes = array(
'selectedComment' => self::generate_id( $comment ),
'commentId' => $comment->comment_ID,
);
- $div = sprintf(
- '
',
- esc_attr( wp_json_encode( $attrs ) )
- );
+ $block = \do_blocks( \sprintf( '', \wp_json_encode( $attributes ) ) );
/**
* Filters the HTML markup for the ActivityPub remote comment reply container.
*
- * @param string $div The HTML markup for the remote reply container. Default is a div
- * with class 'activitypub-remote-reply' and data attributes for
- * the selected comment ID and internal comment ID.
+ * @param string $block The HTML markup for the remote reply container.
*/
- return apply_filters( 'activitypub_comment_reply_link', $div );
+ return \apply_filters( 'activitypub_comment_reply_link', $block );
}
/**
@@ -342,6 +362,38 @@ public static function comment_class( $classes, $css_class, $comment_id ) {
return $classes;
}
+ /**
+ * Makes the comment feed filterable by comment type.
+ *
+ * Also excludes ActivityPub comment types from the feed when no type is specified.
+ *
+ * @param string $where The `WHERE` clause for the comment feed query.
+ *
+ * @return string The modified `WHERE` clause.
+ */
+ public static function comment_feed_where( $where ) {
+ global $wpdb;
+
+ $comment_type = \get_query_var( 'type' );
+
+ if ( 'all' === $comment_type ) {
+ return $where;
+ }
+
+ $comment_types = self::get_comment_type_slugs();
+
+ if ( \in_array( $comment_type, $comment_types, true ) ) {
+ $where .= $wpdb->prepare( ' AND comment_type = %s', $comment_type );
+ } else {
+ $comment_types = \array_map( 'esc_sql', $comment_types );
+ $placeholders = implode( ', ', array_fill( 0, count( $comment_types ), '%s' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.NotPrepared
+ $where .= $wpdb->prepare( sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
+ }
+
+ return $where;
+ }
+
/**
* Gets the public comment id via the WordPress comments meta.
*
@@ -395,9 +447,12 @@ public static function remote_comment_link( $comment_link, $comment ) {
return $comment_link;
}
- $public_comment_link = self::get_source_url( $comment->comment_ID );
+ $remote_comment_link = null;
+ if ( 'comment' === $comment->comment_type ) {
+ $remote_comment_link = self::get_source_url( $comment->comment_ID );
+ }
- return $public_comment_link ?? $comment_link;
+ return $remote_comment_link ?? $comment_link;
}
@@ -452,60 +507,6 @@ private static function post_has_remote_comments( $post_id ) {
return ! empty( $comments );
}
- /**
- * Enqueue scripts for remote comments
- */
- public static function enqueue_scripts() {
- if ( ! \is_singular() || \is_user_logged_in() ) {
- // Only on single pages, only for logged-out users.
- return;
- }
-
- if ( ! \post_type_supports( \get_post_type(), 'activitypub' ) ) {
- // Post type does not support ActivityPub.
- return;
- }
-
- if ( ! \comments_open() || ! \get_comments_number() ) {
- // No comments, no need to load the script.
- return;
- }
-
- if ( ! self::post_has_remote_comments( \get_the_ID() ) ) {
- // No remote comments, no need to load the script.
- return;
- }
-
- $handle = 'activitypub-remote-reply';
- $data = array(
- 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
- 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
- );
- $js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
- $asset_file = ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply/index.asset.php';
-
- if ( \file_exists( $asset_file ) ) {
- $assets = require_once $asset_file;
-
- \wp_enqueue_script(
- $handle,
- \plugins_url( 'build/remote-reply/index.js', __DIR__ ),
- $assets['dependencies'],
- $assets['version'],
- true
- );
- \wp_add_inline_script( $handle, $js, 'before' );
- \wp_set_script_translations( $handle, 'activitypub' );
-
- \wp_enqueue_style(
- $handle,
- \plugins_url( 'build/remote-reply/style-index.css', __DIR__ ),
- array( 'wp-components' ),
- $assets['version']
- );
- }
- }
-
/**
* Get the comment type by activity type.
*
@@ -563,19 +564,6 @@ public static function get_comment_type_slugs() {
return array_keys( self::get_comment_types() );
}
- /**
- * Return the registered custom comment type slugs.
- *
- * @deprecated 4.5.0 Use get_comment_type_slugs instead.
- *
- * @return array The registered custom comment type slugs.
- */
- public static function get_comment_type_names() {
- _deprecated_function( __METHOD__, '4.5.0', 'get_comment_type_slugs' );
-
- return self::get_comment_type_slugs();
- }
-
/**
* Get the custom comment type.
*
@@ -742,6 +730,14 @@ public static function pre_comment_approved( $approved, $comment_data ) {
return $approved;
}
+ // Maybe auto-approve likes and reposts.
+ if (
+ \in_array( $comment_data['comment_type'], self::get_comment_type_slugs(), true ) &&
+ '1' === \get_option( 'activitypub_auto_approve_reactions' )
+ ) {
+ return 1;
+ }
+
if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
return $approved;
}
diff --git a/includes/class-debug.php b/includes/class-debug.php
index ce91edf70..28629813b 100644
--- a/includes/class-debug.php
+++ b/includes/class-debug.php
@@ -19,9 +19,12 @@ class Debug {
public static function init() {
if ( \WP_DEBUG && \WP_DEBUG_LOG ) {
\add_action( 'activitypub_safe_remote_post_response', array( self::class, 'log_remote_post_responses' ), 10, 2 );
+
\add_action( 'activitypub_inbox', array( self::class, 'log_inbox' ), 10, 3 );
\add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'log_inbox' ), 10, 3 );
+ \add_action( 'activitypub_add_to_outbox_failed', array( self::class, 'log_outbox_error' ), 10, 4 );
+
\add_action( 'activitypub_sent_to_inbox', array( self::class, 'log_sent_to_inbox' ), 10, 2 );
}
}
@@ -51,10 +54,25 @@ public static function log_inbox( $data, $user_id, $type ) {
$actor = $data['actor'] ?? '';
$url = object_to_uri( $actor );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
- \error_log( "[INBOX] Request From: {$url} with Activity: " . \print_r( $data, true ) );
+ \error_log( "[INBOX] Request from: {$url} with Activity: " . \print_r( $data, true ) );
}
}
+ /**
+ * Log failed outbox requests.
+ *
+ * @param false|\WP_Error $error The error object or false.
+ * @param array $data The Activity array.
+ * @param string $type The type of the request.
+ * @param int $user_id The ID of the local blog user.
+ */
+ public static function log_outbox_error( $error, $data, $type, $user_id ) {
+ $error_message = \is_wp_error( $error ) ? $error->get_error_message() : 'Unknown';
+
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions
+ \error_log( "[OUTBOX] Failed to add {$type}-Activity from: {$user_id} (Error: {$error_message}) with Activity: " . \print_r( $data, true ) );
+ }
+
/**
* Logs Follower notifications.
*
diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php
index cbd46add0..0c9b6e370 100644
--- a/includes/class-dispatcher.php
+++ b/includes/class-dispatcher.php
@@ -45,29 +45,6 @@ public static function init() {
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_by_mentioned_actors' ), 10, 3 );
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_replied_urls' ), 10, 3 );
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_relays' ), 10, 3 );
-
- // Fallback for `activitypub_send_to_inboxes` filter.
- \add_filter(
- 'activitypub_additional_inboxes',
- function ( $inboxes, $actor_id, $activity ) {
- /**
- * Filters the list of interactees inboxes to send the Activity to.
- *
- * @param array $inboxes The list of inboxes to send to.
- * @param int $actor_id The actor ID.
- * @param Activity $activity The ActivityPub Activity.
- *
- * @deprecated 5.2.0 Use `activitypub_additional_inboxes` instead.
- * @deprecated 5.4.0 Use `activitypub_additional_inboxes` instead.
- */
- $inboxes = \apply_filters_deprecated( 'activitypub_send_to_inboxes', array( $inboxes, $actor_id, $activity ), '5.2.0', 'activitypub_additional_inboxes' );
- $inboxes = \apply_filters_deprecated( 'activitypub_interactees_inboxes', array( $inboxes, $actor_id, $activity ), '5.4.0', 'activitypub_additional_inboxes' );
-
- return $inboxes;
- },
- 10,
- 3
- );
}
/**
@@ -364,23 +341,6 @@ public static function add_inboxes_of_replied_urls( $inboxes, $actor_id, $activi
return $inboxes;
}
- /**
- * Adds Blog Actor inboxes to Updates so the Blog User's followers are notified of edits.
- *
- * @deprecated 5.2.0 Use {@see Followers::maybe_add_inboxes_of_blog_user} instead.
- *
- * @param array $inboxes The list of Inboxes.
- * @param int $actor_id The WordPress Actor-ID.
- * @param Activity $activity The ActivityPub Activity.
- *
- * @return array The filtered Inboxes.
- */
- public static function maybe_add_inboxes_of_blog_user( $inboxes, $actor_id, $activity ) { // phpcs:ignore
- _deprecated_function( __METHOD__, '5.2.0', 'Followers::maybe_add_inboxes_of_blog_user' );
-
- return $inboxes;
- }
-
/**
* Check if passed Activity is public.
*
diff --git a/includes/class-embed.php b/includes/class-embed.php
index bb503a923..e7d4362f3 100644
--- a/includes/class-embed.php
+++ b/includes/class-embed.php
@@ -132,8 +132,8 @@ public static function get_html_for_object( $activity_object, $inline_css = true
);
if ( $inline_css ) {
- // Grab the CSS.
- $css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $path = \is_rtl() ? 'build/embed/embed-rtl.css' : 'build/embed/embed.css';
+ $css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
// We embed CSS directly because this may be in an iframe.
printf( '', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
diff --git a/includes/class-handler.php b/includes/class-handler.php
index b0ead9b4e..595318691 100644
--- a/includes/class-handler.php
+++ b/includes/class-handler.php
@@ -7,12 +7,14 @@
namespace Activitypub;
+use Activitypub\Handler\Accept;
use Activitypub\Handler\Announce;
use Activitypub\Handler\Create;
use Activitypub\Handler\Delete;
use Activitypub\Handler\Follow;
use Activitypub\Handler\Like;
use Activitypub\Handler\Move;
+use Activitypub\Handler\Reject;
use Activitypub\Handler\Undo;
use Activitypub\Handler\Update;
@@ -31,14 +33,16 @@ public static function init() {
* Register handlers.
*/
public static function register_handlers() {
+ Accept::init();
Announce::init();
Create::init();
Delete::init();
Follow::init();
- Undo::init();
- Update::init();
Like::init();
Move::init();
+ Reject::init();
+ Undo::init();
+ Update::init();
/**
* Register additional handlers.
diff --git a/includes/class-hashtag.php b/includes/class-hashtag.php
index 8012f1df2..cfe5084fc 100644
--- a/includes/class-hashtag.php
+++ b/includes/class-hashtag.php
@@ -32,10 +32,7 @@ public static function init() {
* @return array The filtered activity object array.
*/
public static function filter_activity_object( $activity ) {
- /* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
- Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
- */
- if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
+ if ( ! empty( $activity['summary'] ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
diff --git a/includes/class-http.php b/includes/class-http.php
index 9f9a8dd0e..b1e7402f3 100644
--- a/includes/class-http.php
+++ b/includes/class-http.php
@@ -35,19 +35,14 @@ public static function post( $url, $body, $user_id ) {
*/
\do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
- $date = \gmdate( 'D, d M Y H:i:s T' );
- $digest = Signature::generate_digest( $body );
- $signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
-
- $wp_version = get_masked_wp_version();
-
/**
* Filters the HTTP headers user agent string.
*
* @param string $user_agent The user agent string.
*/
- $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
+ $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ) );
$args = array(
+ 'method' => 'POST',
'timeout' => 100,
'limit_response_size' => 1048576,
'redirection' => 3,
@@ -55,13 +50,15 @@ public static function post( $url, $body, $user_id ) {
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
- 'Digest' => $digest,
- 'Signature' => $signature,
- 'Date' => $date,
+ 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
),
'body' => $body,
+ 'key_id' => Actors::get_by_id( $user_id )->get_id() . '#main-key',
+ 'private_key' => Actors::get_private_key( $user_id ),
);
+ $args = Signature::sign_request( $args, $url );
+
$response = \wp_safe_remote_post( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
@@ -123,11 +120,6 @@ public static function get( $url, $cached = false ) {
}
}
- $date = \gmdate( 'D, d M Y H:i:s T' );
- $signature = Signature::generate_signature( Actors::APPLICATION_USER_ID, 'get', $url, $date );
-
- $wp_version = get_masked_wp_version();
-
/**
* Filters the HTTP headers user agent string.
*
@@ -136,7 +128,7 @@ public static function get( $url, $cached = false ) {
*
* @param string $user_agent The user agent string.
*/
- $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
+ $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ) );
/**
* Filters the timeout duration for remote GET requests in ActivityPub.
@@ -146,6 +138,7 @@ public static function get( $url, $cached = false ) {
$timeout = \apply_filters( 'activitypub_remote_get_timeout', 100 );
$args = array(
+ 'method' => 'GET',
'timeout' => $timeout,
'limit_response_size' => 1048576,
'redirection' => 3,
@@ -153,11 +146,14 @@ public static function get( $url, $cached = false ) {
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
- 'Signature' => $signature,
- 'Date' => $date,
+ 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
),
+ 'key_id' => Actors::get_by_id( Actors::APPLICATION_USER_ID )->get_id() . '#main-key',
+ 'private_key' => Actors::get_private_key( Actors::APPLICATION_USER_ID ),
);
+ $args = Signature::sign_request( $args, $url );
+
$response = \wp_safe_remote_get( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
@@ -235,6 +231,17 @@ public static function generate_cache_key( $url ) {
* @return array|WP_Error The Object data as array or WP_Error on failure.
*/
public static function get_remote_object( $url_or_object, $cached = true ) {
+ /**
+ * Filters the preemptive return value of a remote object request.
+ *
+ * @param array|string|null $response The response.
+ * @param array|string|null $url_or_object The Object or the Object URL.
+ */
+ $response = apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
+ if ( null !== $response ) {
+ return $response;
+ }
+
$url = object_to_uri( $url_or_object );
if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $url ) ) {
diff --git a/includes/class-link.php b/includes/class-link.php
index 783f1fecd..10a5f7b0e 100644
--- a/includes/class-link.php
+++ b/includes/class-link.php
@@ -28,10 +28,7 @@ public static function init() {
* @return array Rhe activity object array.
*/
public static function filter_activity_object( $activity ) {
- /* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
- Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
- */
- if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
+ if ( ! empty( $activity['summary'] ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
diff --git a/includes/class-mailer.php b/includes/class-mailer.php
index 1e16f6d8c..0b911b9e2 100644
--- a/includes/class-mailer.php
+++ b/includes/class-mailer.php
@@ -88,8 +88,26 @@ public static function comment_notification_text( $message, $comment_id ) {
$post = \get_post( $comment->comment_post_ID );
$comment_author_domain = \gethostbyaddr( $comment->comment_author_IP );
- /* translators: 1: Comment type, 2: Post title */
- $notify_message = \sprintf( html_entity_decode( esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ), \esc_html( $comment_type['singular'] ), \esc_html( $post->post_title ) ) . "\r\n\r\n";
+ // Check if this is a reaction to a post or a comment.
+ if ( 0 === (int) $comment->comment_parent ) {
+ $notify_message = \sprintf(
+ /* translators: 1: Comment type, 2: Post title */
+ \html_entity_decode( esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ),
+ \esc_html( $comment_type['singular'] ),
+ \esc_html( $post->post_title )
+ ) . PHP_EOL . PHP_EOL;
+
+ } else {
+ $parent_comment = \get_comment( $comment->comment_parent );
+ $notify_message = \sprintf(
+ /* translators: 1: Comment type, 2: Post title, 3: Parent comment author */
+ \html_entity_decode( esc_html__( 'New %1$s on your post “%2$s” in reply to %3$s’s comment.', 'activitypub' ) ),
+ \esc_html( $comment_type['singular'] ),
+ \esc_html( $post->post_title ),
+ \esc_html( $parent_comment->comment_author )
+ ) . PHP_EOL . PHP_EOL;
+ }
+
/* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */
$notify_message .= \sprintf( \esc_html__( 'From: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), \esc_html( $comment->comment_author ), \esc_html( $comment->comment_author_IP ), \esc_html( $comment_author_domain ) ) . "\r\n";
/* translators: Reaction author URL. */
diff --git a/includes/class-migration.php b/includes/class-migration.php
index bb4a0c833..ea5225482 100644
--- a/includes/class-migration.php
+++ b/includes/class-migration.php
@@ -7,6 +7,7 @@
namespace Activitypub;
+use Activitypub\Activity\Actor;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Collection\Followers;
@@ -30,22 +31,6 @@ public static function init() {
self::maybe_migrate();
}
- /**
- * Get the target version.
- *
- * This is the version that the database structure will be updated to.
- * It is the same as the plugin version.
- *
- * @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
- *
- * @return string The target version.
- */
- public static function get_target_version() {
- _deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
-
- return ACTIVITYPUB_PLUGIN_VERSION;
- }
-
/**
* The current version of the database structure.
*
@@ -175,14 +160,10 @@ public static function maybe_migrate() {
add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.0.0', '<' ) ) {
- Scheduler::register_schedules();
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) );
\wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) );
add_action( 'init', 'flush_rewrite_rules', 20 );
}
- if ( \version_compare( $version_from_db, '5.2.0', '<' ) ) {
- Scheduler::register_schedules();
- }
if ( \version_compare( $version_from_db, '5.4.0', '<' ) ) {
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) );
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) );
@@ -195,6 +176,32 @@ public static function maybe_migrate() {
self::update_notification_options();
}
+ if ( \version_compare( $version_from_db, '6.0.0', '<' ) ) {
+ self::migrate_followers_to_ap_actor_cpt();
+ \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
+ }
+
+ if ( \version_compare( $version_from_db, '6.0.1', '<' ) ) {
+ self::migrate_followers_to_ap_actor_cpt();
+ \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
+ }
+
+ if ( \version_compare( $version_from_db, '7.0.0', '<' ) ) {
+ wp_unschedule_hook( 'activitypub_update_followers' );
+ wp_unschedule_hook( 'activitypub_cleanup_followers' );
+
+ if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
+ \wp_schedule_event( time(), 'hourly', 'activitypub_update_remote_actors' );
+ }
+
+ if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
+ \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_remote_actors' );
+ }
+ }
+
+ // Ensure all required cron schedules are registered.
+ Scheduler::register_schedules();
+
/*
* Add new update routines above this comment. ^
*
@@ -491,7 +498,7 @@ public static function migrate_to_4_7_2() {
global $wpdb;
// phpcs:ignore WordPress.DB
$followers = $wpdb->get_col(
- $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Followers::POST_TYPE )
+ $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Actors::POST_TYPE )
);
foreach ( $followers as $id ) {
clean_post_cache( $id );
@@ -835,7 +842,7 @@ private static function add_default_extra_field() {
}
/**
- * Rename meta keys.
+ * Rename user meta keys.
*
* @param string $old_key The old comment meta key.
* @param string $new_key The new comment meta key.
@@ -852,6 +859,24 @@ private static function update_usermeta_key( $old_key, $new_key ) {
);
}
+ /**
+ * Update post meta keys.
+ *
+ * @param string $old_key The old post meta key.
+ * @param string $new_key The new post meta key.
+ */
+ private static function update_postmeta_key( $old_key, $new_key ) {
+ global $wpdb;
+
+ $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->postmeta,
+ array( 'meta_key' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ array( 'meta_key' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ array( '%s' ),
+ array( '%s' )
+ );
+ }
+
/**
* Rename option keys.
*
@@ -941,4 +966,86 @@ public static function update_notification_options() {
\delete_option( 'activitypub_mailer_new_dm' );
\delete_option( 'activitypub_mailer_new_follower' );
}
+
+ /**
+ * Migrate followers to the new CPT.
+ */
+ public static function migrate_followers_to_ap_actor_cpt() {
+ global $wpdb;
+
+ $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->posts,
+ array( 'post_type' => Actors::POST_TYPE ),
+ array( 'post_type' => 'ap_follower' ),
+ array( '%s' ),
+ array( '%s' )
+ );
+
+ self::update_postmeta_key( '_activitypub_user_id', Followers::FOLLOWER_META_KEY );
+ }
+
+ /**
+ * Update _activitypub_actor_json meta values to ensure they are properly slashed.
+ *
+ * @param int $batch_size Optional. Number of meta values to process per batch. Default 100.
+ *
+ * @return array|void Array with batch size and offset if there are more meta values to process, void otherwise.
+ */
+ public static function update_actor_json_storage( $batch_size = 100 ) {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $meta_values = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d",
+ $batch_size
+ )
+ );
+
+ $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
+ if ( $has_kses ) {
+ // Prevent KSES from corrupting JSON in post_content.
+ \kses_remove_filters();
+ }
+
+ foreach ( $meta_values as $meta ) {
+ $post = \get_post( $meta->post_id );
+
+ if ( ! $post ) {
+ \delete_post_meta( $meta->post_id, '_activitypub_actor_json' );
+ continue;
+ }
+
+ $post_content = \json_decode( $meta->meta_value, true );
+
+ if ( \json_last_error() !== JSON_ERROR_NONE ) {
+ $post_content = Http::get_remote_object( $post->guid );
+
+ if ( \is_wp_error( $post_content ) ) {
+ \delete_post_meta( $post->ID, '_activitypub_actor_json' );
+ continue;
+ }
+ }
+
+ \wp_update_post(
+ array(
+ 'ID' => $post->ID,
+ 'post_content' => \wp_slash( \wp_json_encode( $post_content ) ),
+ )
+ );
+
+ \delete_post_meta( $post->ID, '_activitypub_actor_json' );
+ }
+
+ if ( $has_kses ) {
+ // Restore KSES filters.
+ \kses_init_filters();
+ }
+
+ if ( \count( $meta_values ) === $batch_size ) {
+ return array(
+ 'batch_size' => $batch_size,
+ );
+ }
+ }
}
diff --git a/includes/class-options.php b/includes/class-options.php
index ee7978e31..a015ebb41 100644
--- a/includes/class-options.php
+++ b/includes/class-options.php
@@ -137,7 +137,8 @@ public static function maybe_disable_interactions( $pre ) {
* Default max image attachments.
*
* @param string $value The value of the option.
- * @return string|int
+ *
+ * @return string|int The value of the option.
*/
public static function default_max_image_attachments( $value ) {
if ( ! \is_numeric( $value ) ) {
diff --git a/includes/class-query.php b/includes/class-query.php
index 2af21e51f..ea5eae870 100644
--- a/includes/class-query.php
+++ b/includes/class-query.php
@@ -267,50 +267,41 @@ protected function get_request_url() {
* @return bool True if the request is an ActivityPub request, false otherwise.
*/
public function is_activitypub_request() {
- if ( isset( $this->is_activitypub_request ) ) {
- return $this->is_activitypub_request;
- }
-
- global $wp_query;
+ if ( ! isset( $this->is_activitypub_request ) ) {
+ global $wp_query;
- // One can trigger an ActivityPub request by adding `?activitypub` to the URL.
- if (
- isset( $wp_query->query_vars['activitypub'] ) ||
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- isset( $_GET['activitypub'] )
- ) {
- \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
- $this->is_activitypub_request = true;
+ $this->is_activitypub_request = false;
- return true;
- }
-
- /*
- * The other (more common) option to make an ActivityPub request
- * is to send an Accept header.
- */
- if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
- $accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
-
- /*
- * $accept can be a single value, or a comma separated list of values.
- * We want to support both scenarios,
- * and return true when the header includes at least one of the following:
- * - application/activity+json
- * - application/ld+json
- * - application/json
- */
- if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
+ // One can trigger an ActivityPub request by adding `?activitypub` to the URL.
+ if ( isset( $wp_query->query_vars['activitypub'] ) || isset( $_GET['activitypub'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
\defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
$this->is_activitypub_request = true;
- return true;
+ // The other (more common) option to make an ActivityPub request is to send an Accept header.
+ } elseif ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
+ $accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
+
+ /*
+ * $accept can be a single value, or a comma separated list of values.
+ * We want to support both scenarios,
+ * and return true when the header includes at least one of the following:
+ * - application/activity+json
+ * - application/ld+json
+ * - application/json
+ */
+ if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
+ \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
+ $this->is_activitypub_request = true;
+ }
}
}
- $this->is_activitypub_request = false;
-
- return false;
+ /**
+ * Filters whether the current request is an ActivityPub request.
+ *
+ * @param bool $is_activitypub_request True if the request is an ActivityPub request, false otherwise.
+ */
+ return \apply_filters( 'activitypub_is_activitypub_request', $this->is_activitypub_request );
}
/**
diff --git a/includes/class-sanitize.php b/includes/class-sanitize.php
index 42d32491d..0f9be1c83 100644
--- a/includes/class-sanitize.php
+++ b/includes/class-sanitize.php
@@ -7,6 +7,7 @@
namespace Activitypub;
+use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
/**
@@ -32,6 +33,50 @@ public static function url_list( $value ) {
return \array_values( $value );
}
+ /**
+ * Sanitize and normalize a list of account identifiers to ActivityPub IDs.
+ *
+ * This function processes various identifier formats, such as URLs and
+ * webfinger identifiers, and normalizes them into a consistent format.
+ *
+ * @param string|array $value The value to sanitize.
+ *
+ * @return array The sanitized and normalized list of account identifiers.
+ */
+ public static function identifier_list( $value ) {
+ if ( ! \is_array( $value ) ) {
+ $value = \explode( PHP_EOL, $value );
+ }
+
+ $value = \array_filter( $value );
+ $uris = array();
+
+ foreach ( $value as $uri ) {
+ $uri = \trim( $uri );
+ $uri = \ltrim( $uri, '@' );
+
+ if ( \is_email( $uri ) ) {
+ $_uri = Webfinger::resolve( $uri );
+ if ( \is_wp_error( $_uri ) ) {
+ $uris[] = $uri;
+ continue;
+ }
+
+ $uri = $_uri;
+ }
+
+ $uri = \sanitize_url( $uri );
+ $actor = Actors::fetch_remote_by_uri( $uri );
+ if ( \is_wp_error( $actor ) ) {
+ $uris[] = $uri;
+ } else {
+ $uris[] = \sanitize_url( $actor->guid );
+ }
+ }
+
+ return \array_values( \array_unique( $uris ) );
+ }
+
/**
* Sanitize a list of hosts.
*
@@ -72,6 +117,10 @@ public static function blog_identifier( $value ) {
$sanitized = \array_map( 'sanitize_title', $parts );
$sanitized = \implode( '.', $sanitized );
+ if ( empty( $sanitized ) ) {
+ return Blog::get_default_username();
+ }
+
// Check for login or nicename.
$user = new \WP_User_Query(
array(
@@ -119,4 +168,18 @@ public static function constant_value( $value ) {
return $value;
}
+
+ /**
+ * Sanitize a webfinger identifier.
+ *
+ * @param string $value The value to sanitize.
+ *
+ * @return string The sanitized webfinger identifier.
+ */
+ public static function webfinger( $value ) {
+ $value = \str_replace( 'acct:', '', $value );
+ $value = \trim( $value, '@' );
+
+ return $value;
+ }
}
diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php
index a6c475618..d34d93919 100644
--- a/includes/class-scheduler.php
+++ b/includes/class-scheduler.php
@@ -14,7 +14,6 @@
use Activitypub\Scheduler\Comment;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
-use Activitypub\Collection\Followers;
/**
* Scheduler class.
@@ -42,8 +41,8 @@ public static function init() {
);
// Follower Cleanups.
- \add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) );
- \add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) );
+ \add_action( 'activitypub_update_remote_actors', array( self::class, 'update_remote_actors' ) );
+ \add_action( 'activitypub_cleanup_remote_actors', array( self::class, 'cleanup_remote_actors' ) );
// Event callbacks.
\add_action( 'activitypub_async_batch', array( self::class, 'async_batch' ), 10, 99 );
@@ -78,12 +77,12 @@ public static function register_schedulers() {
* Schedule all ActivityPub schedules.
*/
public static function register_schedules() {
- if ( ! \wp_next_scheduled( 'activitypub_update_followers' ) ) {
- \wp_schedule_event( time(), 'hourly', 'activitypub_update_followers' );
+ if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
+ \wp_schedule_event( time(), 'hourly', 'activitypub_update_remote_actors' );
}
- if ( ! \wp_next_scheduled( 'activitypub_cleanup_followers' ) ) {
- \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_followers' );
+ if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
+ \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_remote_actors' );
}
if ( ! \wp_next_scheduled( 'activitypub_reprocess_outbox' ) ) {
@@ -101,8 +100,8 @@ public static function register_schedules() {
* @return void
*/
public static function deregister_schedules() {
- wp_unschedule_hook( 'activitypub_update_followers' );
- wp_unschedule_hook( 'activitypub_cleanup_followers' );
+ wp_unschedule_hook( 'activitypub_update_remote_actors' );
+ wp_unschedule_hook( 'activitypub_cleanup_remote_actors' );
wp_unschedule_hook( 'activitypub_reprocess_outbox' );
wp_unschedule_hook( 'activitypub_outbox_purge' );
}
@@ -142,9 +141,9 @@ public static function unschedule_events_for_item( $outbox_item_id ) {
}
/**
- * Update followers.
+ * Update remote Actors.
*/
- public static function update_followers() {
+ public static function update_remote_actors() {
$number = 5;
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
@@ -152,31 +151,32 @@ public static function update_followers() {
}
/**
- * Filter the number of followers to update.
+ * Filter the number of remote Actors to update.
*
- * @param int $number The number of followers to update.
+ * @param int $number The number of remote Actors to update.
*/
- $number = apply_filters( 'activitypub_update_followers_number', $number );
- $followers = Followers::get_outdated_followers( $number );
+ $number = apply_filters( 'activitypub_update_remote_actors_number', $number );
+ $actors = Actors::get_outdated( $number );
- foreach ( $followers as $follower ) {
- $meta = get_remote_metadata_by_actor( $follower->get_id(), false );
+ foreach ( $actors as $actor ) {
+ $meta = get_remote_metadata_by_actor( $actor->guid, false );
if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
- Followers::add_error( $follower->get__id(), $meta );
+ Actors::add_error( $actor->ID, 'Failed to fetch or parse metadata' );
} else {
- $follower->from_array( $meta );
- $follower->update();
-
- $follower->clear_errors();
+ $id = Actors::upsert( $meta );
+ if ( \is_wp_error( $id ) ) {
+ continue;
+ }
+ Actors::clear_errors( $id );
}
}
}
/**
- * Cleanup followers.
+ * Cleanup remote Actors.
*/
- public static function cleanup_followers() {
+ public static function cleanup_remote_actors() {
$number = 5;
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
@@ -184,31 +184,32 @@ public static function cleanup_followers() {
}
/**
- * Filter the number of followers to clean up.
+ * Filter the number of remote Actors to clean up.
*
- * @param int $number The number of followers to clean up.
+ * @param int $number The number of remote Actors to clean up.
*/
- $number = apply_filters( 'activitypub_update_followers_number', $number );
- $followers = Followers::get_faulty_followers( $number );
+ $number = apply_filters( 'activitypub_cleanup_remote_actors_number', $number );
+ $actors = Actors::get_faulty( $number );
- foreach ( $followers as $follower ) {
- $meta = get_remote_metadata_by_actor( $follower->get_url(), false );
+ foreach ( $actors as $actor ) {
+ $meta = get_remote_metadata_by_actor( $actor->guid, false );
if ( is_tombstone( $meta ) ) {
- $follower->delete();
- } elseif ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
- if ( $follower->count_errors() >= 5 ) {
- $follower->delete();
- \wp_schedule_single_event(
- \time(),
- 'activitypub_delete_actor_interactions',
- array( $follower->get_id() )
- );
+ \wp_delete_post( $actor->ID );
+ } elseif ( empty( $meta ) || ! is_array( $meta ) || \is_wp_error( $meta ) ) {
+ if ( Actors::count_errors( $actor->ID ) >= 5 ) {
+ \wp_schedule_single_event( \time(), 'activitypub_delete_actor_interactions', array( $actor->guid ) );
+ \wp_delete_post( $actor->ID );
} else {
- Followers::add_error( $follower->get__id(), $meta );
+ Actors::add_error( $actor->ID, $meta );
}
} else {
- $follower->reset_errors();
+ $id = Actors::upsert( $meta );
+ if ( \is_wp_error( $id ) ) {
+ Actors::add_error( $actor->ID, $id );
+ } else {
+ Actors::clear_errors( $actor->ID );
+ }
}
}
}
diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php
index 2bc06af5b..1554fa646 100644
--- a/includes/class-shortcodes.php
+++ b/includes/class-shortcodes.php
@@ -312,32 +312,12 @@ public static function image( $attributes, $content, $tag ) {
/**
* Generates output for the 'ap_hashcats' Shortcode.
*
+ * @deprecated 7.0.0
+ *
* @return string The post categories as hashtags.
*/
public static function hashcats() {
- $item = self::get_item();
-
- if ( ! $item ) {
- return '';
- }
-
- $categories = \get_the_category( $item->ID );
-
- if ( ! $categories ) {
- return '';
- }
-
- $hash_tags = array();
-
- foreach ( $categories as $category ) {
- $hash_tags[] = \sprintf(
- '%s ',
- \esc_url( \get_category_link( $category ) ),
- esc_hashtag( $category->name )
- );
- }
-
- return \implode( ' ', $hash_tags );
+ return '';
}
/**
diff --git a/includes/class-signature.php b/includes/class-signature.php
index af71d7629..a5420cc60 100644
--- a/includes/class-signature.php
+++ b/includes/class-signature.php
@@ -7,11 +7,9 @@
namespace Activitypub;
-use WP_Error;
-use DateTime;
-use DateTimeZone;
-use WP_REST_Request;
use Activitypub\Collection\Actors;
+use Activitypub\Signature\Http_Signature_Draft;
+use Activitypub\Signature\Http_Message_Signature;
/**
* ActivityPub Signature Class.
@@ -22,168 +20,240 @@
class Signature {
/**
- * Return the public key for a given user.
+ * Sign an HTTP Request.
*
- * @param int $user_id The WordPress User ID.
- * @param bool $force Optional. Force the generation of a new key pair. Default false.
+ * @param array $args An array of HTTP request arguments.
+ * @param string $url The request URL.
*
- * @return mixed The public key.
+ * @return array Request arguments with signature headers.
*/
- public static function get_public_key_for( $user_id, $force = false ) {
- if ( $force ) {
- self::generate_key_pair_for( $user_id );
- }
+ public static function sign_request( $args, $url ) {
+ // Bail if there's nothing to sign with.
+ if ( ! isset( $args['key_id'], $args['private_key'] ) ) {
+ return $args;
+ }
+
+ $args = \wp_parse_args(
+ $args,
+ array(
+ 'method' => 'GET',
+ 'headers' => array(
+ 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
+ ),
+ )
+ );
- $key_pair = self::get_keypair_for( $user_id );
+ if ( '1' === \get_option( 'activitypub_rfc9421_signature' ) && self::could_support_rfc9421( $url ) ) {
+ $signature = new Http_Message_Signature();
+ \add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 );
+ } else {
+ $signature = new Http_Signature_Draft();
+ }
- return $key_pair['public_key'];
+ return $signature->sign( $args, $url );
}
/**
- * Return the private key for a given user.
+ * Verifies the http signatures
*
- * @param int $user_id The WordPress User ID.
- * @param bool $force Optional. Force the generation of a new key pair. Default false.
+ * @param \WP_REST_Request|array $request The request object or $_SERVER array.
*
- * @return mixed The private key.
+ * @return bool|\WP_Error A boolean or WP_Error.
*/
- public static function get_private_key_for( $user_id, $force = false ) {
- if ( $force ) {
- self::generate_key_pair_for( $user_id );
+ public static function verify_http_signature( $request ) {
+ if ( is_object( $request ) ) { // REST Request object.
+ $body = $request->get_body();
+ $headers = $request->get_headers();
+ $headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . self::get_route( $request );
+ } else {
+ $headers = self::format_server_request( $request );
+ $headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
}
- $key_pair = self::get_keypair_for( $user_id );
+ $signature = isset( $headers['signature_input'] ) ? new Http_Message_Signature() : new Http_Signature_Draft();
- return $key_pair['private_key'];
+ return $signature->verify( $headers, $body ?? null );
}
/**
- * Return the key pair for a given user.
+ * If a request with RFC-9421 signature fails, we try again with the Draft Cavage signature.
*
- * @param int $user_id The WordPress User ID.
+ * @param array $response HTTP response.
+ * @param array $parsed_args HTTP request arguments.
+ * @param string $url The request URL.
*
- * @return array The key pair.
+ * @return array The HTTP response.
*/
- public static function get_keypair_for( $user_id ) {
- $option_key = self::get_signature_options_key_for( $user_id );
- $key_pair = \get_option( $option_key );
+ public static function maybe_double_knock( $response, $parsed_args, $url ) {
+ // Remove this filter to prevent infinite recursion.
+ \remove_filter( 'http_response', array( self::class, 'maybe_double_knock' ) );
- if ( ! $key_pair ) {
- $key_pair = self::generate_key_pair_for( $user_id );
+ $response_code = \wp_remote_retrieve_response_code( $response );
+
+ // Fall back to Draft Cavage signature for any 4xx responses.
+ if ( $response_code >= 400 && $response_code < 500 ) {
+ unset( $parsed_args['headers']['Signature'], $parsed_args['headers']['Signature-Input'], $parsed_args['headers']['Content-Digest'] );
+ self::rfc9421_add_unsupported_host( $url );
+
+ $parsed_args = ( new Http_Signature_Draft() )->sign( $parsed_args, $url );
+ $response = \wp_remote_request( $url, $parsed_args );
}
- return $key_pair;
+ return $response;
}
/**
- * Generates the pair keys
+ * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
+ * for use with verify_http_signature().
*
- * @param int $user_id The WordPress User ID.
+ * @param array $server The $_SERVER array.
*
- * @return array The key pair.
+ * @return array $request The formatted request array.
*/
- protected static function generate_key_pair_for( $user_id ) {
- $option_key = self::get_signature_options_key_for( $user_id );
- $key_pair = self::check_legacy_key_pair_for( $user_id );
+ public static function format_server_request( $server ) {
+ $headers = array();
- if ( $key_pair ) {
- \add_option( $option_key, $key_pair );
+ foreach ( $server as $key => $value ) {
+ $key = \str_replace( 'http_', '', \strtolower( $key ) );
+ $headers[ $key ][] = \wp_unslash( $value );
- return $key_pair;
}
- $config = array(
- 'digest_alg' => 'sha512',
- 'private_key_bits' => 2048,
- 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
- );
+ return $headers;
+ }
- $key = \openssl_pkey_new( $config );
- $private_key = null;
- $detail = array();
- if ( $key ) {
- \openssl_pkey_export( $key, $private_key );
+ /**
+ * Returns route.
+ *
+ * @param \WP_REST_Request $request The request object.
+ *
+ * @return string
+ */
+ private static function get_route( $request ) {
+ // Check if the route starts with "index.php".
+ if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
+ $route = $request->get_route();
+ } else {
+ $route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
+ }
+
+ // Fix route for subdirectory installations.
+ $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
- $detail = \openssl_pkey_get_details( $key );
+ if ( \is_string( $path ) ) {
+ $path = trim( $path, '/' );
}
- // Check if keys are valid.
- if (
- empty( $private_key ) || ! is_string( $private_key ) ||
- ! isset( $detail['key'] ) || ! is_string( $detail['key'] )
- ) {
- return array(
- 'private_key' => null,
- 'public_key' => null,
- );
+ if ( $path ) {
+ $route = '/' . $path . $route;
}
- $key_pair = array(
- 'private_key' => $private_key,
- 'public_key' => $detail['key'],
- );
+ return $route;
+ }
- // Persist keys.
- \add_option( $option_key, $key_pair );
+ /**
+ * Check if RFC-9421 signature could be supported.
+ *
+ * @param string $url The URL to check.
+ *
+ * @return bool True, if RFC-9421 signature could be supported, false otherwise.
+ */
+ private static function could_support_rfc9421( $url ) {
+ $host = \wp_parse_url( $url, \PHP_URL_HOST );
+ $list = \get_option( 'activitypub_rfc9421_unsupported', array() );
- return $key_pair;
+ if ( isset( $list[ $host ] ) ) {
+ if ( $list[ $host ] > \time() ) {
+ return false;
+ }
+
+ unset( $list[ $host ] );
+ \update_option( 'activitypub_rfc9421_unsupported', $list );
+ }
+
+ return true;
}
/**
- * Return the option key for a given user.
+ * Set RFC-9421 signature unsupported for a given host.
*
- * @param int $user_id The WordPress User ID.
+ * @param string $url The URL to set.
+ */
+ private static function rfc9421_add_unsupported_host( $url ) {
+ $list = \get_option( 'activitypub_rfc9421_unsupported', array() );
+ $host = \wp_parse_url( $url, \PHP_URL_HOST );
+
+ $list[ $host ] = \time() + MONTH_IN_SECONDS;
+ \update_option( 'activitypub_rfc9421_unsupported', $list, false );
+ }
+
+ /**
+ * Return the public key for a given user.
+ *
+ * @deprecated 7.0.0 Use {@see Actors::get_public_key()}.
+ *
+ * @param int $user_id The WordPress User ID.
+ * @param bool $force Optional. Force the generation of a new key pair. Default false.
*
- * @return string The option key.
+ * @return string The public key.
*/
- protected static function get_signature_options_key_for( $user_id ) {
- $id = $user_id;
+ public static function get_public_key_for( $user_id, $force = false ) {
+ \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_public_key' );
- if ( $user_id > 0 ) {
- $user = \get_userdata( $user_id );
- // Sanitize username because it could include spaces and special chars.
- $id = sanitize_title( $user->user_login );
- }
+ return Actors::get_public_key( $user_id, $force );
+ }
- return 'activitypub_keypair_for_' . $id;
+ /**
+ * Return the private key for a given user.
+ *
+ * @deprecated 7.0.0 Use {@see Actors::get_private_key()}.
+ *
+ * @param int $user_id The WordPress User ID.
+ * @param bool $force Optional. Force the generation of a new key pair. Default false.
+ *
+ * @return string The private key.
+ */
+ public static function get_private_key_for( $user_id, $force = false ) {
+ \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_private_key' );
+
+ return Actors::get_private_key( $user_id, $force );
}
/**
- * Check if there is a legacy key pair
+ * Return the key pair for a given user.
+ *
+ * @deprecated 7.0.0 Use {@see Actors::get_keypair()}.
*
* @param int $user_id The WordPress User ID.
*
- * @return array|bool The key pair or false.
+ * @return array The key pair.
*/
- protected static function check_legacy_key_pair_for( $user_id ) {
- switch ( $user_id ) {
- case 0:
- $public_key = \get_option( 'activitypub_blog_user_public_key' );
- $private_key = \get_option( 'activitypub_blog_user_private_key' );
- break;
- case -1:
- $public_key = \get_option( 'activitypub_application_user_public_key' );
- $private_key = \get_option( 'activitypub_application_user_private_key' );
- break;
- default:
- $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
- $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
- break;
- }
+ public static function get_keypair_for( $user_id ) {
+ \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_keypair' );
- if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) {
- return array(
- 'private_key' => $private_key,
- 'public_key' => $public_key,
- );
- }
+ return Actors::get_keypair( $user_id );
+ }
- return false;
+ /**
+ * Get public key from key_id.
+ *
+ * @deprecated 7.0.0 Use {@see Actors::get_remote_key()}.
+ *
+ * @param string $key_id The URL to the public key.
+ *
+ * @return resource|\WP_Error The public key resource or WP_Error.
+ */
+ public static function get_remote_key( $key_id ) {
+ \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_remote_key()' );
+
+ return Actors::get_remote_key( $key_id );
}
/**
* Generates the Signature for an HTTP Request.
*
+ * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
+ *
* @param int $user_id The WordPress User ID.
* @param string $http_method The HTTP method.
* @param string $url The URL to send the request to.
@@ -193,8 +263,10 @@ protected static function check_legacy_key_pair_for( $user_id ) {
* @return string The signature.
*/
public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
+ \_deprecated_function( __METHOD__, 'unreleased', self::class . '::sign_request()' );
+
$user = Actors::get_by_id( $user_id );
- $key = self::get_private_key_for( $user->get__id() );
+ $key = Actors::get_private_key( $user_id );
$url_parts = \wp_parse_url( $url );
@@ -232,129 +304,19 @@ public static function generate_signature( $user_id, $http_method, $url, $date,
}
}
- /**
- * Verifies the http signatures
- *
- * @param WP_REST_Request|array $request The request object or $_SERVER array.
- *
- * @return bool|WP_Error A boolean or WP_Error.
- */
- public static function verify_http_signature( $request ) {
- if ( is_object( $request ) ) { // REST Request object.
- // Check if route starts with "index.php".
- if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
- $route = $request->get_route();
- } else {
- $route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
- }
-
- // Fix route for subdirectory installs.
- $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
-
- if ( \is_string( $path ) ) {
- $path = trim( $path, '/' );
- }
-
- if ( $path ) {
- $route = '/' . $path . $route;
- }
-
- $headers = $request->get_headers();
- $headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . $route;
- } else {
- $request = self::format_server_request( $request );
- $headers = $request['headers']; // $_SERVER array
- $headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
- }
-
- if ( array_key_exists( 'signature', $headers ) ) {
- $signature_block = self::parse_signature_header( $headers['signature'][0] );
- } elseif ( array_key_exists( 'authorization', $headers ) ) {
- $signature_block = self::parse_signature_header( $headers['authorization'][0] );
- } else {
- return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
- }
-
- $signed_headers = $signature_block['headers'];
-
- $signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers );
- if ( ! $signed_data ) {
- return new WP_Error( 'activitypub_signature', __( 'Signed request date outside acceptable time window', 'activitypub' ), array( 'status' => 401 ) );
- }
-
- $algorithm = self::get_signature_algorithm( $signature_block );
- if ( ! $algorithm ) {
- return new WP_Error( 'activitypub_signature', __( 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', 'activitypub' ), array( 'status' => 401 ) );
- }
-
- if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) {
- if ( is_array( $headers['digest'] ) ) {
- $headers['digest'] = $headers['digest'][0];
- }
- $algorithm = 'sha256';
- $digest = explode( '=', $headers['digest'], 2 );
- if ( 'SHA-512' === $digest[0] ) {
- $algorithm = 'sha512';
- }
-
- if ( \base64_encode( \hash( $algorithm, $body, true ) ) !== $digest[1] ) { // phpcs:ignore
- return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 401 ) );
- }
- }
-
- $public_key = self::get_remote_key( $signature_block['keyId'] );
-
- if ( \is_wp_error( $public_key ) ) {
- return $public_key;
- }
-
- $verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
- if ( ! $verified ) {
- return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) );
- }
- return $verified;
- }
-
- /**
- * Get public key from key_id.
- *
- * @param string $key_id The URL to the public key.
- *
- * @return resource|WP_Error The public key resource or WP_Error.
- */
- public static function get_remote_key( $key_id ) {
- $actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) );
- if ( \is_wp_error( $actor ) ) {
- return new WP_Error(
- 'activitypub_no_remote_profile_found',
- __( 'No Profile found or Profile not accessible', 'activitypub' ),
- array( 'status' => 401 )
- );
- }
-
- if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
- $key_resource = \openssl_pkey_get_public( \rtrim( $actor['publicKey']['publicKeyPem'] ) );
- if ( $key_resource ) {
- return $key_resource;
- }
- }
-
- return new WP_Error(
- 'activitypub_no_remote_key_found',
- __( 'No Public-Key found', 'activitypub' ),
- array( 'status' => 401 )
- );
- }
-
/**
* Gets the signature algorithm from the signature header.
*
+ * @deprecated 7.0.0 Use {@see Signature::verify()}.
+ *
* @param array $signature_block The signature block.
*
- * @return string The signature algorithm.
+ * @return string|bool The signature algorithm or false if not found.
*/
- public static function get_signature_algorithm( $signature_block ) {
- if ( $signature_block['algorithm'] ) {
+ public static function get_signature_algorithm( $signature_block ) { // phpcs:ignore
+ \_deprecated_function( __METHOD__, 'unreleased', self::class . '::verify' );
+
+ if ( ! empty( $signature_block['algorithm'] ) ) {
switch ( $signature_block['algorithm'] ) {
case 'rsa-sha-512':
return 'sha512'; // hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12.
@@ -362,17 +324,22 @@ public static function get_signature_algorithm( $signature_block ) {
return 'sha256';
}
}
+
return false;
}
/**
* Parses the Signature header.
*
+ * @deprecated 7.0.0 Use {@see Signature::verify()}.
+ *
* @param string $signature The signature header.
*
* @return array Signature parts.
*/
- public static function parse_signature_header( $signature ) {
+ public static function parse_signature_header( $signature ) { // phpcs:ignore
+ \_deprecated_function( __METHOD__, 'unreleased', self::class . '::verify' );
+
$parsed_header = array();
$matches = array();
@@ -405,13 +372,17 @@ public static function parse_signature_header( $signature ) {
/**
* Gets the header data from the included pseudo headers.
*
+ * @deprecated 7.0.0 Use {@see Signature::verify()}.
+ *
* @param array $signed_headers The signed headers.
* @param array $signature_block The signature block.
* @param array $headers The HTTP headers.
*
* @return string signed headers for comparison
*/
- public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
+ public static function get_signed_data( $signed_headers, $signature_block, $headers ) { // phpcs:ignore
+ \_deprecated_function( __METHOD__, 'unreleased', self::class . '::verify' );
+
$signed_data = '';
// This also verifies time-based values by returning false if any of these are out of range.
@@ -458,8 +429,8 @@ public static function get_signed_data( $signed_headers, $signature_block, $head
}
// Allow a bit of leeway for misconfigured clocks.
- $d = new DateTime( $headers[ $header ][0] );
- $d->setTimeZone( new DateTimeZone( 'UTC' ) );
+ $d = new \DateTime( $headers[ $header ][0] );
+ $d->setTimeZone( new \DateTimeZone( 'UTC' ) );
$c = $d->format( 'U' );
$d_plus = time() + ( 3 * HOUR_IN_SECONDS );
@@ -475,44 +446,23 @@ public static function get_signed_data( $signed_headers, $signature_block, $head
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
}
}
+
return \rtrim( $signed_data, "\n" );
}
/**
* Generates the digest for an HTTP Request.
*
+ * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
+ *
* @param string $body The body of the request.
*
* @return string The digest.
*/
public static function generate_digest( $body ) {
+ \_deprecated_function( __METHOD__, 'unreleased', self::class . '::sign_request' );
+
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return "SHA-256=$digest";
}
-
- /**
- * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
- * for use with verify_http_signature().
- *
- * @param array $server The $_SERVER array.
- *
- * @return array $request The formatted request array.
- */
- public static function format_server_request( $server ) {
- $request = array();
- foreach ( $server as $param_key => $param_val ) {
- $req_param = strtolower( $param_key );
- if ( 'REQUEST_URI' === $req_param ) {
- $request['headers']['route'][] = $param_val;
- } else {
- $header_key = str_replace(
- 'http_',
- '',
- $req_param
- );
- $request['headers'][ $header_key ][] = \wp_unslash( $param_val );
- }
- }
- return $request;
- }
}
diff --git a/includes/class-webfinger.php b/includes/class-webfinger.php
index b53aa2855..9f6057975 100644
--- a/includes/class-webfinger.php
+++ b/includes/class-webfinger.php
@@ -211,17 +211,17 @@ public static function get_data( $uri ) {
$webfinger_url = sprintf(
'https://%s/.well-known/webfinger?resource=%s',
$host,
- rawurlencode( $identifier )
+ \rawurlencode( $identifier )
);
- $response = wp_safe_remote_get(
+ $response = \wp_safe_remote_get(
$webfinger_url,
array(
'headers' => array( 'Accept' => 'application/jrd+json' ),
)
);
- if ( is_wp_error( $response ) ) {
+ if ( \is_wp_error( $response ) || \wp_remote_retrieve_response_code( $response ) >= 400 ) {
return new WP_Error(
'webfinger_url_not_accessible',
__( 'The WebFinger Resource is not accessible.', 'activitypub' ),
@@ -232,8 +232,8 @@ public static function get_data( $uri ) {
);
}
- $body = wp_remote_retrieve_body( $response );
- $data = json_decode( $body, true );
+ $body = \wp_remote_retrieve_body( $response );
+ $data = \json_decode( $body, true );
\set_transient( $transient_key, $data, WEEK_IN_SECONDS );
@@ -297,4 +297,16 @@ public static function generate_cache_key( $uri ) {
return 'webfinger_' . md5( $uri );
}
+
+ /**
+ * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
+ * we will try to use what's supplied.
+ *
+ * @param string $uri The URI.
+ *
+ * @return string Hopefully the name of the Follower.
+ */
+ public static function guess( $uri ) {
+ return extract_name_from_uri( $uri ) . '@' . \wp_parse_url( $uri, PHP_URL_HOST );
+ }
}
diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php
index cf7c68a16..4d5b241da 100644
--- a/includes/collection/class-actors.php
+++ b/includes/collection/class-actors.php
@@ -7,12 +7,13 @@
namespace Activitypub\Collection;
-use WP_Error;
-use WP_User_Query;
+use Activitypub\Http;
use Activitypub\Model\User;
use Activitypub\Model\Blog;
use Activitypub\Model\Application;
+use Activitypub\Activity\Actor;
+use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\object_to_uri;
use function Activitypub\normalize_url;
use function Activitypub\normalize_host;
@@ -22,6 +23,8 @@
/**
* Actors collection.
+ *
+ * Provides methods to retrieve, create, update, and manage ActivityPub actors (users, blogs, applications, and remote actors).
*/
class Actors {
/**
@@ -38,12 +41,19 @@ class Actors {
*/
const APPLICATION_USER_ID = -1;
+ /**
+ * Post type for storing remote actors.
+ *
+ * @var string
+ */
+ const POST_TYPE = 'ap_actor';
+
/**
* Get the Actor by ID.
*
- * @param int $user_id The User-ID.
+ * @param int $user_id The user ID.
*
- * @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
+ * @return Actor|User|Blog|Application|\WP_Error Actor object or WP_Error if not found or not permitted.
*/
public static function get_by_id( $user_id ) {
if ( is_numeric( $user_id ) ) {
@@ -51,7 +61,7 @@ public static function get_by_id( $user_id ) {
}
if ( ! user_can_activitypub( $user_id ) ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@@ -71,9 +81,9 @@ public static function get_by_id( $user_id ) {
/**
* Get the Actor by username.
*
- * @param string $username Name of the Actor.
+ * @param string $username Name of the actor.
*
- * @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
+ * @return User|Blog|Application|\WP_Error Actor object or WP_Error if not found.
*/
public static function get_by_username( $username ) {
/**
@@ -93,7 +103,7 @@ public static function get_by_username( $username ) {
\get_option( 'activitypub_blog_identifier' ) === $username
) {
if ( is_user_type_disabled( 'blog' ) ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@@ -109,7 +119,7 @@ public static function get_by_username( $username ) {
}
// Check for 'activitypub_username' meta.
- $user = new WP_User_Query(
+ $user = new \WP_User_Query(
array(
'count_total' => false,
'number' => 1,
@@ -137,7 +147,7 @@ public static function get_by_username( $username ) {
$username = str_replace( array( '*', '%' ), '', $username );
// Check for login or nicename.
- $user = new WP_User_Query(
+ $user = new \WP_User_Query(
array(
'count_total' => false,
'search' => $username,
@@ -155,7 +165,7 @@ public static function get_by_username( $username ) {
}
}
- return new WP_Error(
+ return new \WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@@ -163,17 +173,17 @@ public static function get_by_username( $username ) {
}
/**
- * Get the Actor by resource.
+ * Get the Actor by resource URI (acct, http(s), etc).
*
- * @param string $uri The Actor resource.
+ * @param string $uri The actor resource URI.
*
- * @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
+ * @return User|Blog|Application|\WP_Error Actor object or WP_Error if not found.
*/
public static function get_by_resource( $uri ) {
$uri = object_to_uri( $uri );
if ( ! $uri ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_no_uri',
\__( 'No URI provided', 'activitypub' ),
array( 'status' => 404 )
@@ -195,6 +205,14 @@ public static function get_by_resource( $uri ) {
// Check for http(s) URIs.
case 'http':
case 'https':
+ // Check locally stored remote Actor.
+ $post = self::get_remote_by_uri( $uri );
+
+ if ( ! \is_wp_error( $post ) ) {
+ return self::get_actor( $post );
+ }
+
+ // Check for http(s)://blog.example.com/@username.
$resource_path = \wp_parse_url( $uri, PHP_URL_PATH );
if ( $resource_path ) {
@@ -206,7 +224,6 @@ public static function get_by_resource( $uri ) {
$resource_path = \trim( $resource_path, '/' );
- // Check for http(s)://blog.example.com/@username.
if ( str_starts_with( $resource_path, '@' ) ) {
$identifier = \str_replace( '@', '', $resource_path );
$identifier = \trim( $identifier, '/' );
@@ -232,7 +249,7 @@ public static function get_by_resource( $uri ) {
return self::get_by_id( self::BLOG_USER_ID );
}
- return new WP_Error(
+ return new \WP_Error(
'activitypub_no_user_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
@@ -245,7 +262,7 @@ public static function get_by_resource( $uri ) {
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) {
- return new WP_Error(
+ return new \WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
@@ -259,7 +276,7 @@ public static function get_by_resource( $uri ) {
return self::get_by_username( $identifier );
default:
- return new WP_Error(
+ return new \WP_Error(
'activitypub_wrong_scheme',
\__( 'Wrong scheme', 'activitypub' ),
array( 'status' => 404 )
@@ -268,11 +285,11 @@ public static function get_by_resource( $uri ) {
}
/**
- * Get the Actor by resource.
+ * Get the Actor by various identifier types (ID, URI, username, or email).
*
- * @param string $id The Actor resource.
+ * @param string|int $id Actor identifier (user ID, URI, username, or email).
*
- * @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
+ * @return User|Blog|Application|\WP_Error Actor object or WP_Error if not found.
*/
public static function get_by_various( $id ) {
if ( is_numeric( $id ) ) {
@@ -294,9 +311,9 @@ public static function get_by_various( $id ) {
}
/**
- * Get the Actor collection.
+ * Get the collection of all local user actors.
*
- * @return array The Actor collection.
+ * @return Actor[] Array of User actor objects.
*/
public static function get_collection() {
if ( is_user_type_disabled( 'user' ) ) {
@@ -325,9 +342,9 @@ public static function get_collection() {
}
/**
- * Get all active Actors including the Blog Actor.
+ * Get all active actors, including the Blog actor if enabled.
*
- * @return array The actor collection.
+ * @return array Array of User and Blog actor objects.
*/
public static function get_all() {
$return = array();
@@ -365,7 +382,8 @@ public static function get_all() {
* Returns the actor type based on the user ID.
*
* @param int $user_id The user ID to check.
- * @return string The user type.
+ *
+ * @return string Actor type: 'user', 'blog', or 'application'.
*/
public static function get_type_by_id( $user_id ) {
$user_id = (int) $user_id;
@@ -380,4 +398,552 @@ public static function get_type_by_id( $user_id ) {
return 'user';
}
+
+ /**
+ * Upsert (insert or update) a remote actor as a custom post type.
+ *
+ * @param array|Actor $actor ActivityPub actor object (array or actor, must include 'id').
+ *
+ * @return int|\WP_Error Post ID on success, WP_Error on failure.
+ */
+ public static function upsert( $actor ) {
+ if ( \is_array( $actor ) ) {
+ $actor = Actor::init_from_array( $actor );
+ }
+
+ $post = self::get_remote_by_uri( $actor->get_id() );
+
+ if ( ! \is_wp_error( $post ) ) {
+ return self::update( $post, $actor );
+ }
+
+ return self::create( $actor );
+ }
+
+ /**
+ * Create a remote actor as a custom post type.
+ *
+ * @param array|Actor $actor ActivityPub actor object (array or Actor, must include 'id').
+ *
+ * @return int|\WP_Error Post ID on success, WP_Error on failure.
+ */
+ public static function create( $actor ) {
+ if ( \is_array( $actor ) ) {
+ $actor = Actor::init_from_array( $actor );
+ }
+
+ $args = self::prepare_custom_post_type( $actor );
+
+ if ( \is_wp_error( $args ) ) {
+ return $args;
+ }
+
+ $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
+ if ( $has_kses ) {
+ // Prevent KSES from corrupting JSON in post_content.
+ \kses_remove_filters();
+ }
+
+ $post_id = \wp_insert_post( $args );
+
+ if ( $has_kses ) {
+ // Restore KSES filters.
+ \kses_init_filters();
+ }
+
+ return $post_id;
+ }
+
+ /**
+ * Update a remote Actor object by actor URL (guid).
+ *
+ * @param int|\WP_Post $post The post ID or object.
+ * @param array|Actor $actor The ActivityPub actor object as associative array (must include 'id').
+ *
+ * @return int|\WP_Error The post ID or WP_Error.
+ */
+ public static function update( $post, $actor ) {
+ if ( \is_array( $actor ) ) {
+ $actor = Actor::init_from_array( $actor );
+ }
+
+ $post = \get_post( $post, ARRAY_A );
+
+ if ( ! $post ) {
+ return new \WP_Error(
+ 'activitypub_actor_not_found',
+ \__( 'Actor not found', 'activitypub' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $args = self::prepare_custom_post_type( $actor );
+
+ if ( \is_wp_error( $args ) ) {
+ return $args;
+ }
+
+ $args = \wp_parse_args( $args, $post );
+
+ $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
+ if ( $has_kses ) {
+ // Prevent KSES from corrupting JSON in post_content.
+ \kses_remove_filters();
+ }
+
+ $post_id = \wp_update_post( $args );
+
+ if ( $has_kses ) {
+ // Restore KSES filters.
+ \kses_init_filters();
+ }
+
+ return $post_id;
+ }
+
+ /**
+ * Delete a remote actor object by actor URL (guid).
+ *
+ * @param int $post_id The post ID.
+ *
+ * @return bool True on success, false on failure.
+ */
+ public static function delete( $post_id ) {
+ return \wp_delete_post( $post_id );
+ }
+
+ /**
+ * Get a remote actor post by actor URI (guid).
+ *
+ * @param string $actor_uri The actor URI.
+ *
+ * @return \WP_Post|\WP_Error Post object or WP_Error if not found.
+ */
+ public static function get_remote_by_uri( $actor_uri ) {
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $post_id = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
+ esc_sql( $actor_uri ),
+ esc_sql( self::POST_TYPE )
+ )
+ );
+
+ if ( ! $post_id ) {
+ return new \WP_Error(
+ 'activitypub_actor_not_found',
+ \__( 'Actor not found', 'activitypub' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return \get_post( $post_id );
+ }
+
+ /**
+ * Lookup a remote actor post by actor URI (guid), fetching from remote if not found locally.
+ *
+ * @param string $actor_uri The actor URI.
+ *
+ * @return \WP_Post|\WP_Error Post object or WP_Error if not found.
+ */
+ public static function fetch_remote_by_uri( $actor_uri ) {
+ $post = self::get_remote_by_uri( $actor_uri );
+
+ if ( ! \is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $object = Http::get_remote_object( $actor_uri, false );
+
+ if ( \is_wp_error( $object ) ) {
+ return $object;
+ }
+
+ $post_id = self::upsert( $object );
+
+ if ( \is_wp_error( $post_id ) ) {
+ return $post_id;
+ }
+
+ return \get_post( $post_id );
+ }
+
+ /**
+ * Store an error that occurred when sending an ActivityPub message to a follower.
+ *
+ * The error will be stored in post meta.
+ *
+ * @param int $post_id The ID of the WordPress Custom-Post-Type.
+ * @param string|\WP_Error $error The error message.
+ *
+ * @return int|false The meta ID on success, false on failure.
+ */
+ public static function add_error( $post_id, $error ) {
+ if ( \is_string( $error ) ) {
+ $error_message = $error;
+ } elseif ( \is_wp_error( $error ) ) {
+ $error_message = $error->get_error_message();
+ } else {
+ $error_message = \__(
+ 'Unknown Error or misconfigured Error-Message',
+ 'activitypub'
+ );
+ }
+
+ return \add_post_meta(
+ $post_id,
+ '_activitypub_errors',
+ $error_message
+ );
+ }
+
+ /**
+ * Count the errors for an actor.
+ *
+ * @param int $post_id The ID of the WordPress Custom-Post-Type.
+ *
+ * @return int The number of errors.
+ */
+ public static function count_errors( $post_id ) {
+ return \count( \get_post_meta( $post_id, '_activitypub_errors', false ) );
+ }
+
+ /**
+ * Get all error messages for an actor.
+ *
+ * @param int $post_id The post ID.
+ *
+ * @return string[] Array of error messages.
+ */
+ public static function get_errors( $post_id ) {
+ return \get_post_meta( $post_id, '_activitypub_errors', false );
+ }
+
+ /**
+ * Clear all errors for an actor.
+ *
+ * @param int $post_id The ID of the WordPress Custom-Post-Type.
+ *
+ * @return bool True on success, false on failure.
+ */
+ public static function clear_errors( $post_id ) {
+ return \delete_post_meta( $post_id, '_activitypub_errors' );
+ }
+
+ /**
+ * Get all remote actors (Custom Post Type) that had errors.
+ *
+ * @param int $number Optional. Number of actors to return. Default 20.
+ *
+ * @return \WP_Post[] Array of faulty actor posts.
+ */
+ public static function get_faulty( $number = 20 ) {
+ $args = array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => $number,
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ 'relation' => 'OR',
+ array(
+ 'key' => '_activitypub_errors',
+ 'compare' => 'EXISTS',
+ ),
+ array(
+ 'key' => '_activitypub_inbox',
+ 'compare' => 'NOT EXISTS',
+ ),
+ array(
+ 'key' => '_activitypub_inbox',
+ 'value' => '',
+ 'compare' => '=',
+ ),
+ ),
+ );
+
+ return ( new \WP_Query() )->query( $args );
+ }
+
+ /**
+ * Get all remote actor posts not updated for a given time.
+ *
+ * @param int $number Optional. Limits the result. Default 50.
+ * @param int $older_than Optional. The time in seconds. Default DAY_IN_SECONDS.
+ *
+ * @return \WP_Post[] The list of actors.
+ */
+ public static function get_outdated( $number = 50, $older_than = DAY_IN_SECONDS ) {
+ $args = array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => $number,
+ 'orderby' => 'modified',
+ 'order' => 'ASC',
+ 'post_status' => 'any', // 'any' includes 'trash'.
+ 'date_query' => array(
+ array(
+ 'column' => 'post_modified_gmt',
+ 'before' => \gmdate( 'Y-m-d', \time() - $older_than ),
+ ),
+ ),
+ );
+
+ return ( new \WP_Query() )->query( $args );
+ }
+
+ /**
+ * Convert a custom post type input to an Activitypub\Activity\Actor.
+ *
+ * @param int|\WP_Post $post The post ID or object.
+ *
+ * @return Actor|\WP_Error The actor object or WP_Error on failure.
+ */
+ public static function get_actor( $post ) {
+ $post = \get_post( $post );
+
+ if ( ! $post ) {
+ return new \WP_Error(
+ 'activitypub_actor_not_found',
+ \__( 'Actor not found', 'activitypub' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $json = $post->post_content;
+
+ if ( empty( $json ) ) {
+ $json = \get_post_meta( $post->ID, '_activitypub_actor_json', true );
+ }
+
+ $actor = Actor::init_from_json( $json );
+
+ if ( \is_wp_error( $actor ) ) {
+ self::add_error( $post->ID, $actor );
+ }
+
+ return $actor;
+ }
+
+ /**
+ * Prepare actor object for insert or update as a custom post type.
+ *
+ * @param Actor $actor The actor data.
+ *
+ * @return array|\WP_Error Array of post arguments or WP_Error on failure.
+ */
+ private static function prepare_custom_post_type( $actor ) {
+ if ( ! $actor instanceof Actor ) {
+ return new \WP_Error(
+ 'activitypub_invalid_actor_data',
+ \__( 'Invalid actor data', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! empty( $actor->get_endpoints()['sharedInbox'] ) ) {
+ $inbox = $actor->get_endpoints()['sharedInbox'];
+ } elseif ( ! empty( $actor->get_inbox() ) ) {
+ $inbox = $actor->get_inbox();
+ } else {
+ return new \WP_Error(
+ 'activitypub_invalid_actor_data',
+ \__( 'Invalid actor data', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return array(
+ 'guid' => \esc_url_raw( $actor->get_id() ),
+ 'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?? $actor->get_preferred_username() ) ),
+ 'post_author' => 0,
+ 'post_type' => self::POST_TYPE,
+ 'post_content' => \wp_slash( $actor->to_json() ),
+ 'post_excerpt' => \wp_kses( \wp_slash( $actor->get_summary() ), 'user_description' ),
+ 'post_status' => 'publish',
+ 'meta_input' => array(
+ '_activitypub_inbox' => $inbox,
+ ),
+ );
+ }
+
+ /**
+ * Return the public key for a given actor.
+ *
+ * @param int $user_id The WordPress User ID.
+ * @param bool $force Optional. Force the generation of a new key pair. Default false.
+ *
+ * @return string The public key.
+ */
+ public static function get_public_key( $user_id, $force = false ) {
+ if ( $force ) {
+ self::generate_key_pair( $user_id );
+ }
+
+ $key_pair = self::get_keypair( $user_id );
+
+ return $key_pair['public_key'];
+ }
+
+ /**
+ * Return the private key for a given actor.
+ *
+ * @param int $user_id The WordPress User ID.
+ * @param bool $force Optional. Force the generation of a new key pair. Default false.
+ *
+ * @return string The private key.
+ */
+ public static function get_private_key( $user_id, $force = false ) {
+ if ( $force ) {
+ self::generate_key_pair( $user_id );
+ }
+
+ $key_pair = self::get_keypair( $user_id );
+
+ return $key_pair['private_key'];
+ }
+
+ /**
+ * Return the key pair for a given actor.
+ *
+ * @param int $user_id The WordPress User ID.
+ *
+ * @return array The key pair.
+ */
+ public static function get_keypair( $user_id ) {
+ $option_key = self::get_signature_options_key( $user_id );
+ $key_pair = \get_option( $option_key );
+
+ if ( ! $key_pair ) {
+ $key_pair = self::generate_key_pair( $user_id );
+ }
+
+ return $key_pair;
+ }
+
+ /**
+ * Get public key from key_id.
+ *
+ * @param string $key_id The URL to the public key.
+ *
+ * @return resource|\WP_Error The public key resource or WP_Error.
+ */
+ public static function get_remote_key( $key_id ) {
+ $actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) );
+ if ( \is_wp_error( $actor ) ) {
+ return new \WP_Error( 'activitypub_no_remote_profile_found', 'No Profile found or Profile not accessible', array( 'status' => 401 ) );
+ }
+
+ if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
+ $key_resource = \openssl_pkey_get_public( \rtrim( $actor['publicKey']['publicKeyPem'] ) );
+ if ( $key_resource ) {
+ return $key_resource;
+ }
+ }
+
+ return new \WP_Error( 'activitypub_no_remote_key_found', 'No Public-Key found', array( 'status' => 401 ) );
+ }
+
+ /**
+ * Generates the pair of keys.
+ *
+ * @param int $user_id The WordPress User ID.
+ *
+ * @return array The key pair.
+ */
+ protected static function generate_key_pair( $user_id ) {
+ $option_key = self::get_signature_options_key( $user_id );
+ $key_pair = self::check_legacy_key_pair( $user_id );
+
+ if ( $key_pair ) {
+ \add_option( $option_key, $key_pair );
+
+ return $key_pair;
+ }
+
+ $config = array(
+ 'digest_alg' => 'sha512',
+ 'private_key_bits' => 2048,
+ 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
+ );
+
+ $key = \openssl_pkey_new( $config );
+ $private_key = null;
+ $detail = array();
+ if ( $key ) {
+ \openssl_pkey_export( $key, $private_key );
+
+ $detail = \openssl_pkey_get_details( $key );
+ }
+
+ // Check if keys are valid.
+ if (
+ empty( $private_key ) || ! is_string( $private_key ) ||
+ ! isset( $detail['key'] ) || ! is_string( $detail['key'] )
+ ) {
+ return array(
+ 'private_key' => null,
+ 'public_key' => null,
+ );
+ }
+
+ $key_pair = array(
+ 'private_key' => $private_key,
+ 'public_key' => $detail['key'],
+ );
+
+ // Persist keys.
+ \add_option( $option_key, $key_pair );
+
+ return $key_pair;
+ }
+
+ /**
+ * Return the option key for a given user.
+ *
+ * @param int $user_id The WordPress User ID.
+ *
+ * @return string The option key.
+ */
+ protected static function get_signature_options_key( $user_id ) {
+ if ( $user_id > 0 ) {
+ $user = \get_userdata( $user_id );
+ // Sanitize username because it could include spaces and special chars.
+ $user_id = \sanitize_title( $user->user_login );
+ }
+
+ return 'activitypub_keypair_for_' . $user_id;
+ }
+
+ /**
+ * Check if there is a legacy key pair
+ *
+ * @param int $user_id The WordPress User ID.
+ *
+ * @return array|bool The key pair or false.
+ */
+ protected static function check_legacy_key_pair( $user_id ) {
+ switch ( $user_id ) {
+ case 0:
+ $public_key = \get_option( 'activitypub_blog_user_public_key' );
+ $private_key = \get_option( 'activitypub_blog_user_private_key' );
+ break;
+ case -1:
+ $public_key = \get_option( 'activitypub_application_user_public_key' );
+ $private_key = \get_option( 'activitypub_application_user_private_key' );
+ break;
+ default:
+ $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
+ $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
+ break;
+ }
+
+ if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) {
+ return array(
+ 'private_key' => $private_key,
+ 'public_key' => $public_key,
+ );
+ }
+
+ return false;
+ }
}
diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php
index 5c3733901..7cd8ce515 100644
--- a/includes/collection/class-followers.php
+++ b/includes/collection/class-followers.php
@@ -7,10 +7,6 @@
namespace Activitypub\Collection;
-use Activitypub\Model\Follower;
-use WP_Error;
-use WP_Query;
-
use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor;
@@ -21,16 +17,27 @@
* @author Matthias Pfefferle
*/
class Followers {
- const POST_TYPE = 'ap_follower';
+ /**
+ * Cache key for the followers inbox.
+ *
+ * @var string
+ */
const CACHE_KEY_INBOXES = 'follower_inboxes_%s';
+ /**
+ * Meta key for the followers user ID.
+ *
+ * @var string
+ */
+ const FOLLOWER_META_KEY = '_activitypub_following';
+
/**
* Add new Follower.
*
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
- * @return Follower|WP_Error The Follower (WP_Post array) or an WP_Error.
+ * @return int|\WP_Error The Follower ID or an WP_Error.
*/
public static function add_follower( $user_id, $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
@@ -39,57 +46,73 @@ public static function add_follower( $user_id, $actor ) {
return $meta;
}
- if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
- return new WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
+ if ( empty( $meta ) || ! \is_array( $meta ) || \is_wp_error( $meta ) ) {
+ return new \WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
}
- $follower = new Follower();
- $follower->from_array( $meta );
-
- $id = $follower->upsert();
+ $post_id = Actors::upsert( $meta );
+ if ( \is_wp_error( $post_id ) ) {
+ return $post_id;
+ }
- if ( is_wp_error( $id ) ) {
- return $id;
+ $post_meta = \get_post_meta( $post_id, self::FOLLOWER_META_KEY, false );
+ if ( \is_array( $post_meta ) && ! \in_array( (string) $user_id, $post_meta, true ) ) {
+ \add_post_meta( $post_id, self::FOLLOWER_META_KEY, $user_id );
+ \wp_cache_delete( \sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
}
- $post_meta = get_post_meta( $id, '_activitypub_user_id', false );
+ return $post_id;
+ }
+
+ /**
+ * Remove a Follower.
+ *
+ * @param int $post_id The ID of the remote Actor.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return bool True on success, false on failure.
+ */
+ public static function remove( $post_id, $user_id ) {
+ $post = \get_post( $post_id );
- // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
- if ( is_array( $post_meta ) && ! in_array( $user_id, $post_meta ) ) {
- add_post_meta( $id, '_activitypub_user_id', $user_id );
- wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
+ if ( ! $post ) {
+ return false;
}
- return $follower;
+ \wp_cache_delete( \sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
+
+ /**
+ * Fires before a Follower is removed.
+ *
+ * @param \WP_Post $post The remote Actor object.
+ * @param int $user_id The ID of the WordPress User.
+ * @param \Activitypub\Activity\Actor $actor The remote Actor object.
+ */
+ \do_action( 'activitypub_followers_pre_remove_follower', $post, $user_id, Actors::get_actor( $post ) );
+
+ return \delete_post_meta( $post_id, self::FOLLOWER_META_KEY, $user_id );
}
/**
* Remove a Follower.
*
+ * @deprecated Use Activitypub\Collection\Followers::remove instead.
+ *
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return bool True on success, false on failure.
*/
public static function remove_follower( $user_id, $actor ) {
- wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
+ _deprecated_function( __METHOD__, 'unreleased', 'Activitypub\Collection\Followers::remove' );
- $follower = self::get_follower( $user_id, $actor );
+ $remote_actor = self::get_follower( $user_id, $actor );
- if ( ! $follower ) {
+ if ( \is_wp_error( $remote_actor ) ) {
return false;
}
- /**
- * Fires before a Follower is removed.
- *
- * @param Follower $follower The Follower object.
- * @param int $user_id The ID of the WordPress User.
- * @param string $actor The Actor URL.
- */
- do_action( 'activitypub_followers_pre_remove_follower', $follower, $user_id, $actor );
-
- return delete_post_meta( $follower->get__id(), '_activitypub_user_id', $user_id );
+ return self::remove( $remote_actor->ID, $user_id );
}
/**
@@ -98,29 +121,33 @@ public static function remove_follower( $user_id, $actor ) {
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
- * @return Follower|false|null The Follower object or null
+ * @return \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_follower( $user_id, $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
- $post_id = $wpdb->get_var(
+ $id = $wpdb->get_var(
$wpdb->prepare(
- "SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = '_activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s",
+ "SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value = %d AND p.guid = %s",
array(
- esc_sql( self::POST_TYPE ),
- esc_sql( $user_id ),
- esc_sql( $actor ),
+ \esc_sql( Actors::POST_TYPE ),
+ \esc_sql( self::FOLLOWER_META_KEY ),
+ \esc_sql( $user_id ),
+ \esc_sql( $actor ),
)
)
);
- if ( $post_id ) {
- $post = get_post( $post_id );
- return Follower::init_from_cpt( $post );
+ if ( ! $id ) {
+ return new \WP_Error(
+ 'activitypub_follower_not_found',
+ \__( 'Follower not found', 'activitypub' ),
+ array( 'status' => 404 )
+ );
}
- return null;
+ return \get_post( $id );
}
/**
@@ -128,25 +155,12 @@ public static function get_follower( $user_id, $actor ) {
*
* @param string $actor The Actor URL.
*
- * @return Follower|false|null The Follower object or false on failure.
+ * @return \WP_Post|\WP_Error The Follower object or WP_Error on failure.
*/
public static function get_follower_by_actor( $actor ) {
- global $wpdb;
+ _deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_remote_by_uri' );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery
- $post_id = $wpdb->get_var(
- $wpdb->prepare(
- "SELECT ID FROM $wpdb->posts WHERE guid=%s",
- esc_sql( $actor )
- )
- );
-
- if ( $post_id ) {
- $post = get_post( $post_id );
- return Follower::init_from_cpt( $post );
- }
-
- return null;
+ return Actors::get_remote_by_uri( $actor );
}
/**
@@ -157,10 +171,11 @@ public static function get_follower_by_actor( $actor ) {
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
- * @return Follower[] List of `Follower` objects.
+ * @return \WP_Post[] List of `Follower` objects.
*/
public static function get_followers( $user_id, $number = -1, $page = null, $args = array() ) {
$data = self::get_followers_with_count( $user_id, $number, $page, $args );
+
return $data['followers'];
}
@@ -175,19 +190,25 @@ public static function get_followers( $user_id, $number = -1, $page = null, $arg
* @return array {
* Data about the followers.
*
- * @type Follower[] $followers List of `Follower` objects.
+ * @type \WP_Post[] $followers List of `Follower` objects.
* @type int $total Total number of followers.
* }
*/
public static function get_followers_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
- 'post_type' => self::POST_TYPE,
+ 'post_type' => Actors::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
+ 'relation' => 'OR',
+ array(
+ 'key' => self::FOLLOWER_META_KEY,
+ 'value' => $user_id,
+ ),
+ // for backwards compatibility.
array(
'key' => '_activitypub_user_id',
'value' => $user_id,
@@ -195,37 +216,12 @@ public static function get_followers_with_count( $user_id, $number = -1, $page =
),
);
- $args = wp_parse_args( $args, $defaults );
- $query = new WP_Query( $args );
+ $args = \wp_parse_args( $args, $defaults );
+ $query = new \WP_Query( $args );
$total = $query->found_posts;
- $followers = array_map( array( Follower::class, 'init_from_cpt' ), $query->get_posts() );
- $followers = array_filter( $followers );
+ $followers = \array_filter( $query->posts );
- return compact( 'followers', 'total' );
- }
-
- /**
- * Get all Followers.
- *
- * @return Follower[] The Term list of Followers.
- */
- public static function get_all_followers() {
- $args = array(
- 'nopaging' => true,
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'meta_query' => array(
- 'relation' => 'AND',
- array(
- 'key' => '_activitypub_inbox',
- 'compare' => 'EXISTS',
- ),
- array(
- 'key' => '_activitypub_actor_json',
- 'compare' => 'EXISTS',
- ),
- ),
- );
- return self::get_followers( null, null, null, $args );
+ return \compact( 'followers', 'total' );
}
/**
@@ -236,30 +232,7 @@ public static function get_all_followers() {
* @return int The number of Followers
*/
public static function count_followers( $user_id ) {
- $query = new WP_Query(
- array(
- 'post_type' => self::POST_TYPE,
- 'fields' => 'ids',
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'meta_query' => array(
- 'relation' => 'AND',
- array(
- 'key' => '_activitypub_user_id',
- 'value' => $user_id,
- ),
- array(
- 'key' => '_activitypub_inbox',
- 'compare' => 'EXISTS',
- ),
- array(
- 'key' => '_activitypub_actor_json',
- 'compare' => 'EXISTS',
- ),
- ),
- )
- );
-
- return $query->found_posts;
+ return self::get_followers_with_count( $user_id )['total'];
}
/**
@@ -270,18 +243,18 @@ public static function count_followers( $user_id ) {
* @return array The list of Inboxes.
*/
public static function get_inboxes( $user_id ) {
- $cache_key = sprintf( self::CACHE_KEY_INBOXES, $user_id );
- $inboxes = wp_cache_get( $cache_key, 'activitypub' );
+ $cache_key = \sprintf( self::CACHE_KEY_INBOXES, $user_id );
+ $inboxes = \wp_cache_get( $cache_key, 'activitypub' );
if ( $inboxes ) {
return $inboxes;
}
// Get all Followers of an ID of the WordPress User.
- $posts = new WP_Query(
+ $posts = new \WP_Query(
array(
'nopaging' => true,
- 'post_type' => self::POST_TYPE,
+ 'post_type' => Actors::POST_TYPE,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
@@ -291,7 +264,7 @@ public static function get_inboxes( $user_id ) {
'compare' => 'EXISTS',
),
array(
- 'key' => '_activitypub_user_id',
+ 'key' => self::FOLLOWER_META_KEY,
'value' => $user_id,
),
array(
@@ -303,9 +276,7 @@ public static function get_inboxes( $user_id ) {
)
);
- $posts = $posts->get_posts();
-
- if ( ! $posts ) {
+ if ( ! $posts->posts ) {
return array();
}
@@ -314,15 +285,15 @@ public static function get_inboxes( $user_id ) {
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
- WHERE post_id IN (" . implode( ', ', array_fill( 0, count( $posts ), '%d' ) ) . ")
+ WHERE post_id IN (" . \implode( ', ', \array_fill( 0, \absint( $posts->post_count ), '%d' ) ) . ")
AND meta_key = '_activitypub_inbox'
AND meta_value IS NOT NULL",
- $posts
+ $posts->posts
)
);
- $inboxes = array_filter( $results );
- wp_cache_set( $cache_key, $inboxes, 'activitypub' );
+ $inboxes = \array_filter( $results );
+ \wp_cache_set( $cache_key, $inboxes, 'activitypub' );
return $inboxes;
}
@@ -341,14 +312,14 @@ public static function get_inboxes_for_activity( $json, $actor_id, $batch_size =
$inboxes = self::get_inboxes( $actor_id );
if ( self::maybe_add_inboxes_of_blog_user( $json, $actor_id ) ) {
- $inboxes = array_fill_keys( $inboxes, 1 );
+ $inboxes = \array_fill_keys( $inboxes, 1 );
foreach ( self::get_inboxes( Actors::BLOG_USER_ID ) as $inbox ) {
$inboxes[ $inbox ] = 1;
}
- $inboxes = array_keys( $inboxes );
+ $inboxes = \array_keys( $inboxes );
}
- return array_slice( $inboxes, $offset, $batch_size );
+ return \array_slice( $inboxes, $offset, $batch_size );
}
/**
@@ -368,9 +339,9 @@ public static function maybe_add_inboxes_of_blog_user( $json, $actor_id ) {
return false;
}
- $activity = json_decode( $json, true );
+ $activity = \json_decode( $json, true );
// Only if this is an Update or Delete. Create handles its own "Announce" in dual user mode.
- if ( ! in_array( $activity['type'] ?? null, array( 'Update', 'Delete' ), true ) ) {
+ if ( ! \in_array( $activity['type'] ?? null, array( 'Update', 'Delete' ), true ) ) {
return false;
}
@@ -378,77 +349,58 @@ public static function maybe_add_inboxes_of_blog_user( $json, $actor_id ) {
}
/**
- * Get all Followers that have not been updated for a given time.
+ * Get all Followers.
*
- * @param int $number Optional. Limits the result. Default 50.
- * @param int $older_than Optional. The time in seconds. Default 86400 (1 day).
+ * @deprecated unreleased Use Activitypub\Collection\Actors::get_all() instead.
*
- * @return Follower[] The Term list of Followers.
+ * @return \WP_Post[] The list of Followers.
*/
- public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
+ public static function get_all_followers() {
+ _deprecated_function( __METHOD__, 'unreleased', 'Activitypub\Collection\Actors::get_all' );
+
$args = array(
- 'post_type' => self::POST_TYPE,
- 'posts_per_page' => $number,
- 'orderby' => 'modified',
- 'order' => 'ASC',
- 'post_status' => 'any', // 'any' includes 'trash'.
- 'date_query' => array(
+ 'nopaging' => true,
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ 'relation' => 'AND',
array(
- 'column' => 'post_modified_gmt',
- 'before' => gmdate( 'Y-m-d', \time() - $older_than ),
+ 'key' => '_activitypub_inbox',
+ 'compare' => 'EXISTS',
),
),
);
+ return self::get_followers( null, null, null, $args );
+ }
- $posts = new WP_Query( $args );
- $items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
+ /**
+ * Get all Followers that have not been updated for a given time.
+ *
+ * @deprecated 7.0.0 Use Activitypub\Collection\Actors::get_outdated() instead.
+ *
+ * @param int $number Optional. Limits the result. Default 50.
+ * @param int $older_than Optional. The time in seconds. Default 86400 (1 day).
+ *
+ * @return \WP_Post[] The list of Actors.
+ */
+ public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
+ _deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_outdated' );
- return array_filter( $items );
+ return Actors::get_outdated( $number, $older_than );
}
/**
* Get all Followers that had errors.
*
+ * @deprecated 7.0.0 Use Activitypub\Collection\Actors::get_faulty() instead.
+ *
* @param int $number Optional. The number of Followers to return. Default 20.
*
- * @return Follower[] The Term list of Followers.
+ * @return \WP_Post[] The list of Actors.
*/
public static function get_faulty_followers( $number = 20 ) {
- $args = array(
- 'post_type' => self::POST_TYPE,
- 'posts_per_page' => $number,
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'meta_query' => array(
- 'relation' => 'OR',
- array(
- 'key' => '_activitypub_errors',
- 'compare' => 'EXISTS',
- ),
- array(
- 'key' => '_activitypub_inbox',
- 'compare' => 'NOT EXISTS',
- ),
- array(
- 'key' => '_activitypub_actor_json',
- 'compare' => 'NOT EXISTS',
- ),
- array(
- 'key' => '_activitypub_inbox',
- 'value' => '',
- 'compare' => '=',
- ),
- array(
- 'key' => '_activitypub_actor_json',
- 'value' => '',
- 'compare' => '=',
- ),
- ),
- );
+ _deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_faulty' );
- $posts = new WP_Query( $args );
- $items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
-
- return array_filter( $items );
+ return Actors::get_faulty( $number );
}
/**
@@ -457,38 +409,46 @@ public static function get_faulty_followers( $number = 20 ) {
*
* The error will be stored in post meta.
*
+ * @deprecated 7.0.0 Use Activitypub\Collection\Actors::add_error() instead.
+ *
* @param int $post_id The ID of the WordPress Custom-Post-Type.
* @param mixed $error The error message. Can be a string or a WP_Error.
*
* @return int|false The meta ID on success, false on failure.
*/
public static function add_error( $post_id, $error ) {
- if ( is_string( $error ) ) {
- $error_message = $error;
- } elseif ( is_wp_error( $error ) ) {
- $error_message = $error->get_error_message();
- } else {
- $error_message = __(
- 'Unknown Error or misconfigured Error-Message',
- 'activitypub'
- );
- }
+ _deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::add_error' );
- return add_post_meta(
- $post_id,
- '_activitypub_errors',
- $error_message
- );
+ return Actors::add_error( $post_id, $error );
}
/**
* Clear the errors for a Follower.
*
+ * @deprecated 7.0.0 Use Activitypub\Collection\Actors::clear_errors() instead.
+ *
* @param int $post_id The ID of the WordPress Custom-Post-Type.
*
* @return bool True on success, false on failure.
*/
public static function clear_errors( $post_id ) {
- return \delete_post_meta( $post_id, '_activitypub_errors' );
+ _deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::clear_errors' );
+
+ return Actors::clear_errors( $post_id );
+ }
+
+ /**
+ * Check the status of a given following.
+ *
+ * @param int $post_id The ID of the Post.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return bool The status of the following.
+ */
+ public static function follows( $post_id, $user_id ) {
+ $all_meta = \get_post_meta( $post_id );
+ $following = $all_meta[ self::FOLLOWER_META_KEY ] ?? array();
+
+ return \in_array( (string) $user_id, $following, true );
}
}
diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php
new file mode 100644
index 000000000..29074386e
--- /dev/null
+++ b/includes/collection/class-following.php
@@ -0,0 +1,435 @@
+ID );
+ $following = $all_meta[ self::FOLLOWING_META_KEY ] ?? array();
+ $pending = $all_meta[ self::PENDING_META_KEY ] ?? array();
+
+ if ( ! \in_array( (string) $user_id, $following, true ) && ! \in_array( (string) $user_id, $pending, true ) ) {
+ $actor = Actors::get_by_id( $user_id );
+
+ if ( \is_wp_error( $actor ) ) {
+ return $actor;
+ }
+
+ \add_post_meta( $post->ID, self::PENDING_META_KEY, (string) $user_id );
+
+ $follow = new Activity();
+ $follow->set_type( 'Follow' );
+ $follow->set_actor( $actor->get_id() );
+ $follow->set_object( $post->guid );
+ $follow->set_to( array( $post->guid ) );
+
+ return add_to_outbox( $follow, null, $user_id );
+ }
+
+ return $post;
+ }
+
+ /**
+ * Accept a follow request.
+ *
+ * @param \WP_Post|int $post The ID of the remote Actor.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return \WP_Post|\WP_Error The ID of the Actor or a WP_Error.
+ */
+ public static function accept( $post, $user_id ) {
+ $post = \get_post( $post );
+
+ if ( ! $post ) {
+ return new \WP_Error( 'activitypub_remote_actor_not_found', 'Remote actor not found' );
+ }
+
+ $following = \get_post_meta( $post->ID, self::PENDING_META_KEY, false );
+
+ if ( ! \is_array( $following ) || ! \in_array( (string) $user_id, $following, true ) ) {
+ return new \WP_Error( 'activitypub_following_not_found', 'Follow request not found' );
+ }
+
+ \add_post_meta( $post->ID, self::FOLLOWING_META_KEY, $user_id );
+ \delete_post_meta( $post->ID, self::PENDING_META_KEY, $user_id );
+
+ return $post;
+ }
+
+ /**
+ * Reject a follow request.
+ *
+ * @param \WP_Post|int $post The ID of the remote Actor.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return \WP_Post|\WP_Error The ID of the Actor or a WP_Error.
+ */
+ public static function reject( $post, $user_id ) {
+ $post = \get_post( $post );
+
+ if ( ! $post ) {
+ return new \WP_Error( 'activitypub_remote_actor_not_found', 'Remote actor not found' );
+ }
+
+ \delete_post_meta( $post->ID, self::PENDING_META_KEY, $user_id );
+ \delete_post_meta( $post->ID, self::FOLLOWING_META_KEY, $user_id );
+
+ return $post;
+ }
+
+ /**
+ * Remove a follow request.
+ *
+ * Please do not use this method directly, use `Activitypub\unfollow` instead.
+ *
+ * @see Activitypub\unfollow
+ *
+ * @param \WP_Post|int $post The ID of the remote Actor.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return \WP_Post|\WP_Error The Actor post or a WP_Error.
+ */
+ public static function unfollow( $post, $user_id ) {
+ $post = \get_post( $post );
+
+ if ( ! $post ) {
+ return new \WP_Error( 'activitypub_remote_actor_not_found', __( 'Remote actor not found', 'activitypub' ) );
+ }
+
+ $actor_type = Actors::get_type_by_id( $user_id );
+
+ \delete_post_meta( $post->ID, self::FOLLOWING_META_KEY, $user_id );
+ \delete_post_meta( $post->ID, self::PENDING_META_KEY, $user_id );
+
+ // Get Post-ID of the Follow Outbox Activity.
+ $post_id_query = new \WP_Query(
+ array(
+ 'post_type' => Outbox::POST_TYPE,
+ 'nopaging' => true,
+ 'posts_per_page' => 1,
+ 'author' => \max( $user_id, 0 ),
+ 'fields' => 'ids',
+ 'number' => 1,
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ array(
+ 'key' => '_activitypub_object_id',
+ 'value' => $post->guid,
+ ),
+ array(
+ 'key' => '_activitypub_activity_type',
+ 'value' => 'Follow',
+ ),
+ array(
+ 'key' => '_activitypub_activity_actor',
+ 'value' => $actor_type,
+ ),
+ ),
+ )
+ );
+
+ if ( $post_id_query->posts ) {
+ Outbox::undo( $post_id_query->posts[0] );
+ }
+
+ return $post;
+ }
+
+ /**
+ * Get the Followings of a given user, along with a total count for pagination purposes.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ * @param int $number Maximum number of results to return.
+ * @param int $page Page number.
+ * @param array $args The WP_Query arguments.
+ *
+ * @return array {
+ * Data about the followings.
+ *
+ * @type \WP_Post[] $followings List of `Following` objects.
+ * @type int $total Total number of followings.
+ * }
+ */
+ public static function get_following_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
+ $defaults = array(
+ 'post_type' => Actors::POST_TYPE,
+ 'posts_per_page' => $number,
+ 'paged' => $page,
+ 'orderby' => 'ID',
+ 'order' => 'DESC',
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ array(
+ 'key' => self::FOLLOWING_META_KEY,
+ 'value' => $user_id,
+ ),
+ ),
+ );
+
+ $args = \wp_parse_args( $args, $defaults );
+ $query = new \WP_Query( $args );
+ $total = $query->found_posts;
+ $following = \array_filter( $query->posts );
+
+ return \compact( 'following', 'total' );
+ }
+
+ /**
+ * Get the Followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ * @param int $number Maximum number of results to return.
+ * @param int $page Page number.
+ * @param array $args The WP_Query arguments.
+ *
+ * @return \WP_Post[] List of `Following` objects.
+ */
+ public static function get_following( $user_id, $number = -1, $page = null, $args = array() ) {
+ $data = self::get_following_with_count( $user_id, $number, $page, $args );
+
+ return $data['following'];
+ }
+
+ /**
+ * Get the total number of followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ *
+ * @return int The total number of followings.
+ */
+ public static function count_following( $user_id ) {
+ return self::get_following_with_count( $user_id, -1, null, array() )['total'];
+ }
+
+ /**
+ * Get the Followings of a given user, along with a total count for pagination purposes.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ * @param int $number Maximum number of results to return.
+ * @param int $page Page number.
+ * @param array $args The WP_Query arguments.
+ *
+ * @return array {
+ * Data about the followings.
+ *
+ * @type \WP_Post[] $followings List of `Following` objects.
+ * @type int $total Total number of followings.
+ * }
+ */
+ public static function get_pending_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
+ $defaults = array(
+ 'post_type' => Actors::POST_TYPE,
+ 'posts_per_page' => $number,
+ 'paged' => $page,
+ 'orderby' => 'ID',
+ 'order' => 'DESC',
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ array(
+ 'key' => self::PENDING_META_KEY,
+ 'value' => $user_id,
+ ),
+ ),
+ );
+
+ $args = \wp_parse_args( $args, $defaults );
+ $query = new \WP_Query( $args );
+ $total = $query->found_posts;
+ $following = \array_filter( $query->posts );
+
+ return \compact( 'following', 'total' );
+ }
+
+ /**
+ * Get the pending followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ * @param int $number Maximum number of results to return.
+ * @param int $page Page number.
+ * @param array $args The WP_Query arguments.
+ *
+ * @return \WP_Post[] List of `Following` objects.
+ */
+ public static function get_pending( $user_id, $number = -1, $page = null, $args = array() ) {
+ $data = self::get_pending_with_count( $user_id, $number, $page, $args );
+
+ return $data['following'];
+ }
+
+ /**
+ * Get the total number of pending followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ *
+ * @return int The total number of pending followings.
+ */
+ public static function count_pending( $user_id ) {
+ return self::get_pending_with_count( $user_id, -1, null, array() )['total'];
+ }
+
+ /**
+ * Get all followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ * @param int $number Maximum number of results to return.
+ * @param int $page Page number.
+ * @param array $args The WP_Query arguments.
+ *
+ * @return \WP_Post[] List of `Following` objects.
+ */
+ public static function get_all_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
+ $defaults = array(
+ 'post_type' => Actors::POST_TYPE,
+ 'posts_per_page' => $number,
+ 'paged' => $page,
+ 'orderby' => 'ID',
+ 'order' => 'DESC',
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ 'relation' => 'OR',
+ array(
+ 'key' => self::FOLLOWING_META_KEY,
+ 'value' => $user_id,
+ ),
+ array(
+ 'key' => self::PENDING_META_KEY,
+ 'value' => $user_id,
+ ),
+ ),
+ );
+
+ $args = \wp_parse_args( $args, $defaults );
+ $query = new \WP_Query( $args );
+ $total = $query->found_posts;
+ $following = \array_filter( $query->posts );
+
+ return \compact( 'following', 'total' );
+ }
+
+ /**
+ * Get all followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ *
+ * @return \WP_Post[] List of `Following` objects.
+ */
+ public static function get_all( $user_id ) {
+ return self::get_all_with_count( $user_id, -1, null, array() )['following'];
+ }
+
+ /**
+ * Get the total number of all followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ *
+ * @return int The total number of all followings.
+ */
+ public static function count_all( $user_id ) {
+ return self::get_all_with_count( $user_id, -1, null, array() )['total'];
+ }
+
+ /**
+ * Get the total number of followings of a given user.
+ *
+ * @param int|null $user_id The ID of the WordPress User.
+ *
+ * @return array Total number of followings and pending followings.
+ */
+ public static function count( $user_id ) {
+ return array(
+ self::ALL => self::get_all_with_count( $user_id, -1, null, array() )['total'],
+ self::ACCEPTED => self::get_following_with_count( $user_id, -1, null, array() )['total'],
+ self::PENDING => self::get_pending_with_count( $user_id, -1, null, array() )['total'],
+ );
+ }
+
+ /**
+ * Check the status of a given following.
+ *
+ * @param int $user_id The ID of the WordPress User.
+ * @param int $post_id The ID of the Post.
+ *
+ * @return string|false The status of the following.
+ */
+ public static function check_status( $user_id, $post_id ) {
+ $all_meta = get_post_meta( $post_id );
+ $following = $all_meta[ self::FOLLOWING_META_KEY ] ?? array();
+ $pending = $all_meta[ self::PENDING_META_KEY ] ?? array();
+
+ if ( \in_array( (string) $user_id, $following, true ) ) {
+ return self::ACCEPTED;
+ }
+
+ if ( \in_array( (string) $user_id, $pending, true ) ) {
+ return self::PENDING;
+ }
+
+ return false;
+ }
+}
diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php
index 968c63e1d..9847d5423 100644
--- a/includes/collection/class-interactions.php
+++ b/includes/collection/class-interactions.php
@@ -89,15 +89,9 @@ public static function update_comment( $activity ) {
*
* @param array $activity Activity array.
*
- * @return array|false Comment data or `false` on failure.
+ * @return array|false Comment data or `false` on failure.
*/
public static function add_reaction( $activity ) {
- $comment_data = self::activity_to_comment( $activity );
-
- if ( ! $comment_data ) {
- return false;
- }
-
$url = object_to_uri( $activity['object'] );
$comment_post_id = \url_to_postid( $url );
$parent_comment_id = url_to_commentid( $url );
@@ -113,16 +107,19 @@ public static function add_reaction( $activity ) {
}
$comment_type = Comment::get_comment_type_by_activity_type( $activity['type'] );
-
if ( ! $comment_type ) {
// Not a valid comment type.
return false;
}
- $comment_content = $comment_type['excerpt'];
+ $comment_data = self::activity_to_comment( $activity );
+ if ( ! $comment_data ) {
+ return false;
+ }
$comment_data['comment_post_ID'] = $comment_post_id;
- $comment_data['comment_content'] = \esc_html( $comment_content );
+ $comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
+ $comment_data['comment_content'] = \esc_html( $comment_type['excerpt'] );
$comment_data['comment_type'] = \esc_attr( $comment_type['type'] );
$comment_data['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] );
@@ -238,20 +235,19 @@ public static function activity_to_comment( $activity ) {
}
// Check Actor-Name.
- if ( isset( $actor['name'] ) ) {
+ $comment_author = null;
+ if ( ! empty( $actor['name'] ) ) {
$comment_author = $actor['name'];
- } elseif ( isset( $actor['preferredUsername'] ) ) {
+ } elseif ( ! empty( $actor['preferredUsername'] ) ) {
$comment_author = $actor['preferredUsername'];
- } else {
+ }
+
+ if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) {
return false;
}
$url = object_to_uri( $actor['url'] ?? $actor['id'] );
- if ( ! $url ) {
- $url = object_to_uri( $actor['id'] );
- }
-
if ( isset( $activity['object']['content'] ) ) {
$comment_content = \addslashes( $activity['object']['content'] );
}
@@ -264,7 +260,7 @@ public static function activity_to_comment( $activity ) {
}
$comment_data = array(
- 'comment_author' => \esc_attr( $comment_author ),
+ 'comment_author' => $comment_author ?? __( 'Anonymous', 'activitypub' ),
'comment_author_url' => \esc_url_raw( $url ),
'comment_content' => $comment_content,
'comment_type' => 'comment',
diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php
index a5546ecde..d27190d64 100644
--- a/includes/collection/class-outbox.php
+++ b/includes/collection/class-outbox.php
@@ -7,8 +7,8 @@
namespace Activitypub\Collection;
-use Activitypub\Dispatcher;
use Activitypub\Scheduler;
+use Activitypub\Webfinger;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
@@ -40,6 +40,17 @@ public static function add( Activity $activity, $user_id, $visibility = ACTIVITY
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
}
+ if ( ! \filter_var( $object_id, FILTER_VALIDATE_URL ) ) {
+ $object_id = Webfinger::resolve( $object_id );
+ }
+
+ if ( \is_wp_error( $object_id ) ) {
+ return $object_id;
+ }
+
+ // Save activity in the context of an activitypub request.
+ \add_filter( 'activitypub_is_activitypub_request', '__return_true' );
+
$outbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
@@ -60,6 +71,8 @@ public static function add( Activity $activity, $user_id, $visibility = ACTIVITY
),
);
+ \remove_filter( 'activitypub_is_activitypub_request', '__return_true' );
+
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
@@ -150,12 +163,16 @@ private static function invalidate_existing_items( $object_id, $activity_type, $
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
*
- * @return int|bool The ID of the outbox item or false on failure.
+ * @return int|bool|\WP_Error The ID of the outbox item or false on failure.
*/
public static function undo( $outbox_item ) {
- $outbox_item = get_post( $outbox_item );
+ $outbox_item = \get_post( $outbox_item );
$activity = self::get_activity( $outbox_item );
+ if ( \is_wp_error( $activity ) ) {
+ return $activity;
+ }
+
$type = 'Undo';
if ( 'Create' === $activity->get_type() ) {
$type = 'Delete';
@@ -166,6 +183,35 @@ public static function undo( $outbox_item ) {
return add_to_outbox( $activity, $type, $outbox_item->post_author );
}
+ /**
+ * Get an outbox item by its GUID.
+ *
+ * @param string $guid The GUID of the outbox item.
+ *
+ * @return \WP_Post|\WP_Error The outbox item or WP_Error.
+ */
+ public static function get_by_guid( $guid ) {
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $post_id = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
+ \esc_url( $guid ),
+ self::POST_TYPE
+ )
+ );
+
+ if ( ! $post_id ) {
+ return new \WP_Error(
+ 'activitypub_outbox_item_not_found',
+ \__( 'Outbox item not found', 'activitypub' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return \get_post( $post_id );
+ }
+
/**
* Reschedule an activity.
*
@@ -193,7 +239,7 @@ public static function reschedule( $outbox_item ) {
* @return Activity|\WP_Error The Activity object or WP_Error.
*/
public static function get_activity( $outbox_item ) {
- $outbox_item = get_post( $outbox_item );
+ $outbox_item = \get_post( $outbox_item );
$actor = self::get_actor( $outbox_item );
if ( is_wp_error( $actor ) ) {
return $actor;
diff --git a/includes/collection/class-users.php b/includes/collection/class-users.php
deleted file mode 100644
index 121949031..000000000
--- a/includes/collection/class-users.php
+++ /dev/null
@@ -1,78 +0,0 @@
- 400 )
@@ -508,11 +491,15 @@ function site_supports_blocks() {
/**
* Check if data is valid JSON.
*
+ * @deprecated unreleased Use {@see \json_decode} instead.
+ *
* @param string $data The data to check.
*
* @return boolean True if the data is JSON, false otherwise.
*/
function is_json( $data ) {
+ \_deprecated_function( __FUNCTION__, 'unreleased', 'json_decode' );
+
return \is_array( \json_decode( $data, true ) );
}
@@ -1208,16 +1195,6 @@ function generate_post_summary( $post, $length = 500 ) {
return '';
}
- $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
-
- if ( $content ) {
- /** This filter is documented in wp-includes/post-template.php */
- return \apply_filters( 'the_excerpt', $content );
- }
-
- $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
- $content_parts = \get_extended( $content );
-
/**
* Filters the excerpt more value.
*
@@ -1226,15 +1203,26 @@ function generate_post_summary( $post, $length = 500 ) {
$excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
$length = $length - strlen( $excerpt_more );
- // Check for the tag.
- if (
- ! empty( $content_parts['extended'] ) &&
- ! empty( $content_parts['main'] )
- ) {
- $content = $content_parts['main'] . ' ' . $excerpt_more;
- $length = null;
+ $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
+
+ if ( $content ) {
+ // Ignore length if excerpt is set.
+ $length = null;
+ } else {
+ $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
+ $content_parts = \get_extended( $content );
+
+ // Check for the tag.
+ if (
+ ! empty( $content_parts['extended'] ) &&
+ ! empty( $content_parts['main'] )
+ ) {
+ $content = \trim( $content_parts['main'] ) . ' ' . $excerpt_more;
+ $length = null;
+ }
}
+ $content = \strip_shortcodes( $content );
$content = \html_entity_decode( $content );
$content = \wp_strip_all_tags( $content );
$content = \trim( $content );
@@ -1247,12 +1235,8 @@ function generate_post_summary( $post, $length = 500 ) {
$content = $content[0] . ' ' . $excerpt_more;
}
- /*
- Removed until this is merged: https://github.com/mastodon/mastodon/pull/28629
- /** This filter is documented in wp-includes/post-template.php
+ // This filter is documented in wp-includes/post-template.php.
return \apply_filters( 'the_excerpt', $content );
- */
- return $content;
}
/**
@@ -1286,7 +1270,7 @@ function get_content_warning( $post_id ) {
function get_user_id( $id ) {
$user = Actors::get_by_id( $id );
- if ( ! $user ) {
+ if ( \is_wp_error( $user ) ) {
return false;
}
@@ -1508,12 +1492,34 @@ function add_to_outbox( $data, $activity_type = null, $user_id = 0, $content_vis
}
if ( ! $activity || \is_wp_error( $activity ) ) {
+ /**
+ * Action triggered when adding an object to the outbox fails.
+ *
+ * @param \WP_Error $activity The error object or false.
+ * @param mixed $data The object that failed to be added to the outbox.
+ * @param string|null $activity_type The type of the Activity or null if `$data` is an Activity.
+ * @param int $user_id The User ID.
+ * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
+ */
+ \do_action( 'activitypub_add_to_outbox_failed', $activity, $data, $activity_type, $user_id, $content_visibility );
+
return false;
}
$outbox_activity_id = Outbox::add( $activity, $user_id, $content_visibility );
- if ( ! $outbox_activity_id ) {
+ if ( ! $outbox_activity_id || \is_wp_error( $outbox_activity_id ) ) {
+ /**
+ * Action triggered when adding an object to the outbox fails.
+ *
+ * @param false|\WP_Error $outbox_activity_id The error object or false.
+ * @param mixed $data The object that failed to be added to the outbox.
+ * @param string|null $activity_type The type of the Activity or null if `$data` is an Activity.
+ * @param int $user_id The User ID.
+ * @param string $content_visibility The visibility of the content. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
+ */
+ \do_action( 'activitypub_add_to_outbox_failed', $outbox_activity_id, $data, $activity_type, $user_id, $content_visibility );
+
return false;
}
@@ -1532,6 +1538,66 @@ function add_to_outbox( $data, $activity_type = null, $user_id = 0, $content_vis
return $outbox_activity_id;
}
+/**
+ * Follow a user.
+ *
+ * @param string|int $remote_actor The Actor URL, WebFinger Resource or Post-ID of the remote Actor.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return \WP_Post|\WP_Error The ID of the Outbox item or a WP_Error.
+ */
+function follow( $remote_actor, $user_id ) {
+ if ( \is_numeric( $remote_actor ) ) {
+ return Following::follow( $remote_actor, $user_id );
+ }
+
+ if ( ! \filter_var( $remote_actor, FILTER_VALIDATE_URL ) ) {
+ $remote_actor = Webfinger::resolve( $remote_actor );
+ }
+
+ if ( \is_wp_error( $remote_actor ) ) {
+ return $remote_actor;
+ }
+
+ $remote_actor_post = Actors::fetch_remote_by_uri( $remote_actor );
+
+ if ( \is_wp_error( $remote_actor_post ) ) {
+ return $remote_actor_post;
+ }
+
+ return Following::follow( $remote_actor_post, $user_id );
+}
+
+/**
+ * Unfollow a user.
+ *
+ * @param string|int $remote_actor The Actor URL, WebFinger Resource or Post-ID of the remote Actor.
+ * @param int $user_id The ID of the WordPress User.
+ *
+ * @return \WP_Post|\WP_Error The ID of the Outbox item or a WP_Error.
+ */
+function unfollow( $remote_actor, $user_id ) {
+ if ( \is_numeric( $remote_actor ) ) {
+ return Following::unfollow( $remote_actor, $user_id );
+ }
+
+ if ( ! \filter_var( $remote_actor, FILTER_VALIDATE_URL ) ) {
+ $remote_actor = Webfinger::resolve( $remote_actor );
+ }
+
+ if ( \is_wp_error( $remote_actor ) ) {
+ return $remote_actor;
+ }
+
+ $remote_actor_post = Actors::fetch_remote_by_uri( $remote_actor );
+
+ if ( \is_wp_error( $remote_actor_post ) ) {
+ return $remote_actor_post;
+ }
+
+ return Following::unfollow( $remote_actor_post, $user_id );
+}
+
/**
* Check if an `$data` is an Activity.
*
@@ -1627,3 +1693,44 @@ function _is_type_of( $data, $types ) {
function get_embed_html( $url, $inline_css = true ) {
return Embed::get_html( $url, $inline_css );
}
+
+/**
+ * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
+ * we will try to use what's supplied.
+ *
+ * @param string $uri The URI.
+ *
+ * @return string Hopefully the name of the Follower.
+ */
+function extract_name_from_uri( $uri ) {
+ $name = $uri;
+
+ if ( \filter_var( $name, FILTER_VALIDATE_URL ) ) {
+ $name = \rtrim( $name, '/' );
+ $path = \wp_parse_url( $name, PHP_URL_PATH );
+ if ( $path ) {
+ if ( \strpos( $name, '@' ) !== false ) {
+ // Expected: https://example.com/@user (default URL pattern).
+ $name = \preg_replace( '|^/@?|', '', $path );
+ } else {
+ // Expected: https://example.com/users/user (default ID pattern).
+ $parts = \explode( '/', $path );
+ $name = \array_pop( $parts );
+ }
+ }
+ } elseif (
+ \is_email( $name ) ||
+ \strpos( $name, 'acct' ) === 0 ||
+ \strpos( $name, '@' ) === 0
+ ) {
+ // Expected: user@example.com or acct:user@example (WebFinger).
+ $name = \ltrim( $name, '@' );
+ if ( str_starts_with( $name, 'acct:' ) ) {
+ $name = \substr( $name, 5 );
+ }
+ $parts = \explode( '@', $name );
+ $name = $parts[0];
+ }
+
+ return $name;
+}
diff --git a/includes/handler/class-accept.php b/includes/handler/class-accept.php
new file mode 100644
index 000000000..c3b090994
--- /dev/null
+++ b/includes/handler/class-accept.php
@@ -0,0 +1,120 @@
+ID, '_activitypub_activity_type', true )
+ ) {
+ return;
+ }
+
+ $actor_post = Actors::get_remote_by_uri( object_to_uri( $accept['object']['object'] ) );
+
+ if ( \is_wp_error( $actor_post ) ) {
+ return;
+ }
+
+ Following::accept( $actor_post, $user_id );
+
+ // Send notification.
+ $notification = new Notification(
+ 'accept',
+ $actor_post->guid,
+ $accept,
+ $user_id
+ );
+ $notification->send();
+ }
+
+ /**
+ * Validate the object.
+ *
+ * @param bool $valid The validation state.
+ * @param string $param The object parameter.
+ * @param \WP_REST_Request $request The request object.
+ *
+ * @return bool The validation state: true if valid, false if not.
+ */
+ public static function validate_object( $valid, $param, $request ) {
+ $json_params = $request->get_json_params();
+
+ if ( empty( $json_params['type'] ) ) {
+ return false;
+ }
+
+ if (
+ 'Accept' !== $json_params['type'] ||
+ \is_wp_error( $request )
+ ) {
+ return $valid;
+ }
+
+ $required_attributes = array(
+ 'actor',
+ 'object',
+ );
+
+ if ( ! empty( \array_diff( $required_attributes, \array_keys( $json_params ) ) ) ) {
+ return false;
+ }
+
+ $required_object_attributes = array(
+ 'id',
+ 'type',
+ 'actor',
+ 'object',
+ );
+
+ if ( ! empty( \array_diff( $required_object_attributes, \array_keys( $json_params['object'] ) ) ) ) {
+ return false;
+ }
+
+ return $valid;
+ }
+}
diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php
index 1aed89523..8f04d5cd1 100644
--- a/includes/handler/class-delete.php
+++ b/includes/handler/class-delete.php
@@ -8,7 +8,7 @@
namespace Activitypub\Handler;
use Activitypub\Http;
-use Activitypub\Collection\Followers;
+use Activitypub\Collection\Actors;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
@@ -101,11 +101,11 @@ public static function handle_delete( $activity ) {
* @param array $activity The delete activity.
*/
public static function maybe_delete_follower( $activity ) {
- $follower = Followers::get_follower_by_actor( $activity['actor'] );
+ $follower = Actors::get_remote_by_uri( $activity['actor'] );
// Verify that Actor is deleted.
- if ( $follower && Http::is_tombstone( $activity['actor'] ) ) {
- $follower->delete();
+ if ( ! is_wp_error( $follower ) && Http::is_tombstone( $activity['actor'] ) ) {
+ Actors::delete( $follower->ID );
self::maybe_delete_interactions( $activity );
}
}
diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php
index a19422113..ec38afe0b 100644
--- a/includes/handler/class-follow.php
+++ b/includes/handler/class-follow.php
@@ -51,25 +51,31 @@ public static function handle_follow( $activity ) {
$user_id = $user->get__id();
// Save follower.
- $follower = Followers::add_follower(
+ $remote_actor = Followers::add_follower(
$user_id,
$activity['actor']
);
+ if ( \is_wp_error( $remote_actor ) ) {
+ return $remote_actor;
+ }
+
+ $remote_actor = \get_post( $remote_actor );
+
/**
* Fires after a new follower has been added.
*
- * @param string $actor The URL of the actor (follower) who initiated the follow.
- * @param array $activity The complete activity data of the follow request.
- * @param int $user_id The ID of the WordPress user being followed.
- * @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object containing the new follower's data.
+ * @param string $actor The URL of the actor (follower) who initiated the follow.
+ * @param array $activity The complete activity data of the follow request.
+ * @param int $user_id The ID of the WordPress user being followed.
+ * @param \WP_Post|\WP_Error $remote_actor The Actor object containing the new follower's data.
*/
- do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $follower );
+ do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $remote_actor );
// Send notification.
$notification = new Notification(
'follow',
- $activity['actor'],
+ $remote_actor->guid,
$activity,
$user_id
);
@@ -79,13 +85,13 @@ public static function handle_follow( $activity ) {
/**
* Send Accept response.
*
- * @param string $actor The Actor URL.
- * @param array $activity_object The Activity object.
- * @param int $user_id The ID of the WordPress User.
- * @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object.
+ * @param string $actor The Actor URL.
+ * @param array $activity_object The Activity object.
+ * @param int $user_id The ID of the WordPress User.
+ * @param \WP_Post|\WP_Error $remote_actor The Actor object.
*/
- public static function queue_accept( $actor, $activity_object, $user_id, $follower ) {
- if ( \is_wp_error( $follower ) ) {
+ public static function queue_accept( $actor, $activity_object, $user_id, $remote_actor ) {
+ if ( \is_wp_error( $remote_actor ) ) {
// Impossible to send a "Reject" because we can not get the Remote-Inbox.
return;
}
diff --git a/includes/handler/class-move.php b/includes/handler/class-move.php
index 71032a457..b990f67a6 100644
--- a/includes/handler/class-move.php
+++ b/includes/handler/class-move.php
@@ -8,6 +8,7 @@
namespace Activitypub\Handler;
use Activitypub\Http;
+use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use function Activitypub\object_to_uri;
@@ -34,30 +35,30 @@ public static function init() {
* @param array $activity The JSON "Move" Activity.
*/
public static function handle_move( $activity ) {
- $target = self::extract_target( $activity );
- $origin = self::extract_origin( $activity );
+ $target_uri = self::extract_target( $activity );
+ $origin_uri = self::extract_origin( $activity );
- if ( ! $target || ! $origin ) {
+ if ( ! $target_uri || ! $origin_uri ) {
return;
}
- $target_object = Http::get_remote_object( $target );
- $origin_object = Http::get_remote_object( $origin );
+ $target_json = Http::get_remote_object( $target_uri );
+ $origin_json = Http::get_remote_object( $origin_uri );
- $verified = self::verify_move( $target_object, $origin_object );
+ $verified = self::verify_move( $target_json, $origin_json );
if ( ! $verified ) {
return;
}
- $target_follower = Followers::get_follower_by_actor( $target );
- $origin_follower = Followers::get_follower_by_actor( $origin );
+ $target_object = Actors::get_remote_by_uri( $target_uri );
+ $origin_object = Actors::get_remote_by_uri( $origin_uri );
/*
* If the new target is followed, but the origin is not,
* everything is fine, so we can return.
*/
- if ( $target_follower && ! $origin_follower ) {
+ if ( ! \is_wp_error( $target_object ) && \is_wp_error( $origin_object ) ) {
return;
}
@@ -65,21 +66,20 @@ public static function handle_move( $activity ) {
* If the new target is not followed, but the origin is,
* update the origin follower to the new target.
*/
- if ( ! $target_follower && $origin_follower ) {
- $origin_follower->from_array( $target_object );
- $origin_follower->set_id( $target );
- $origin_id = $origin_follower->upsert();
-
+ if ( \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$wpdb->posts,
- array( 'guid' => sanitize_url( $target ) ),
- array( 'ID' => sanitize_key( $origin_id ) )
+ array( 'guid' => sanitize_url( $target_uri ) ),
+ array( 'ID' => sanitize_key( $origin_object->ID ) )
);
// Clear the cache.
- wp_cache_delete( $origin_id, 'posts' );
+ \wp_cache_delete( $origin_object->ID, 'posts' );
+
+ Actors::upsert( $target_json );
+
return;
}
@@ -87,18 +87,18 @@ public static function handle_move( $activity ) {
* If the new target is followed, and the origin is followed,
* move users and delete the origin follower.
*/
- if ( $target_follower && $origin_follower ) {
- $origin_users = \get_post_meta( $origin_follower->get__id(), '_activitypub_user_id', false );
- $target_users = \get_post_meta( $target_follower->get__id(), '_activitypub_user_id', false );
+ if ( ! \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) {
+ $origin_users = \get_post_meta( $origin_object->ID, Followers::FOLLOWER_META_KEY, false );
+ $target_users = \get_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, false );
// Get all user ids from $origin_users that are not in $target_users.
$users = \array_diff( $origin_users, $target_users );
foreach ( $users as $user_id ) {
- \add_post_meta( $target_follower->get__id(), '_activitypub_user_id', $user_id );
+ \add_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, $user_id );
}
- $origin_follower->delete();
+ \wp_delete_post( $origin_object->ID );
}
}
diff --git a/includes/handler/class-reject.php b/includes/handler/class-reject.php
new file mode 100644
index 000000000..478e0516b
--- /dev/null
+++ b/includes/handler/class-reject.php
@@ -0,0 +1,119 @@
+ID, '_activitypub_activity_type', true ) ) {
+ case 'Follow':
+ self::reject_follow( $reject, $user_id );
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Reject a "Follow" request.
+ *
+ * @param array $reject The activity-object.
+ * @param int $user_id The id of the local blog-user.
+ */
+ private static function reject_follow( $reject, $user_id ) {
+ $actor_uri = $reject['object']['actor'] ?? '';
+ $actor_post = Actors::get_remote_by_uri( object_to_uri( $actor_uri ) );
+
+ if ( \is_wp_error( $actor_post ) ) {
+ return;
+ }
+
+ Following::reject( $actor_post, $user_id );
+
+ // Send notification.
+ $notification = new Notification(
+ 'reject',
+ $actor_post->guid,
+ $reject,
+ $user_id
+ );
+ $notification->send();
+ }
+
+ /**
+ * Validate the object.
+ *
+ * @param bool $valid The validation state.
+ * @param string $param The object parameter.
+ * @param \WP_REST_Request $request The request object.
+ *
+ * @return bool The validation state: true if valid, false if not.
+ */
+ public static function validate_object( $valid, $param, $request ) {
+ $json_params = $request->get_json_params();
+
+ if ( empty( $json_params['type'] ) ) {
+ return false;
+ }
+
+ if (
+ 'Reject' !== $json_params['type'] ||
+ \is_wp_error( $request )
+ ) {
+ return $valid;
+ }
+
+ if ( empty( $json_params['actor'] ) || empty( $json_params['object'] ) ) {
+ return false;
+ }
+
+ return $valid;
+ }
+}
diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php
index 035bfed78..68fdcc41d 100644
--- a/includes/handler/class-update.php
+++ b/includes/handler/class-update.php
@@ -7,7 +7,7 @@
namespace Activitypub\Handler;
-use Activitypub\Collection\Followers;
+use Activitypub\Collection\Actors;
use Activitypub\Collection\Interactions;
use function Activitypub\get_remote_metadata_by_actor;
@@ -113,13 +113,6 @@ public static function update_actor( $activity ) {
return;
}
- $follower = Followers::get_follower_by_actor( $actor['id'] );
-
- if ( ! $follower ) {
- return;
- }
-
- $follower->from_array( $actor );
- $follower->upsert();
+ Actors::upsert( $actor );
}
}
diff --git a/includes/model/class-application.php b/includes/model/class-application.php
index 0309941ed..12a7563c1 100644
--- a/includes/model/class-application.php
+++ b/includes/model/class-application.php
@@ -7,8 +7,6 @@
namespace Activitypub\Model;
-use WP_Query;
-use Activitypub\Signature;
use Activitypub\Activity\Actor;
use Activitypub\Collection\Actors;
@@ -48,11 +46,18 @@ class Application extends Actor {
protected $indexable = false;
/**
- * The WebFinger Resource.
+ * List of software capabilities implemented by the Application.
*
- * @var string
+ * @see https://codeberg.org/silverpill/feps/src/branch/main/844e/fep-844e.md
+ *
+ * @var array
*/
- protected $webfinger;
+ protected $implements = array(
+ array(
+ 'href' => 'https://datatracker.ietf.org/doc/html/rfc9421',
+ 'name' => 'RFC-9421: HTTP Message Signatures',
+ ),
+ );
/**
* Returns the type of the object.
@@ -173,7 +178,7 @@ public function get_header_image() {
* @return string The published date.
*/
public function get_published() {
- $first_post = new WP_Query(
+ $first_post = new \WP_Query(
array(
'orderby' => 'date',
'order' => 'ASC',
@@ -226,7 +231,7 @@ public function get_public_key() {
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
- 'publicKeyPem' => Signature::get_public_key_for( Actors::APPLICATION_USER_ID ),
+ 'publicKeyPem' => Actors::get_public_key( Actors::APPLICATION_USER_ID ),
);
}
diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php
index e46e5b025..7e2fc1418 100644
--- a/includes/model/class-blog.php
+++ b/includes/model/class-blog.php
@@ -10,8 +10,6 @@
use Activitypub\Activity\Actor;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
-use Activitypub\Signature;
-use WP_Query;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
@@ -25,29 +23,6 @@
* @method int get__id() Gets the internal user ID for the blog (always returns BLOG_USER_ID).
*/
class Blog extends Actor {
- /**
- * The Featured-Posts.
- *
- * @see https://docs.joinmastodon.org/spec/activitypub/#featured
- *
- * @context {
- * "@id": "http://joinmastodon.org/ns#featured",
- * "@type": "@id"
- * }
- *
- * @var string
- */
- protected $featured;
-
- /**
- * Moderators endpoint.
- *
- * @see https://join-lemmy.org/docs/contributors/05-federation.html
- *
- * @var string
- */
- protected $moderators;
-
/**
* The User-ID
*
@@ -56,40 +31,20 @@ class Blog extends Actor {
protected $_id = Actors::BLOG_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
- * If the User is indexable.
- *
- * @context http://joinmastodon.org/ns#indexable
- *
- * @var boolean
- */
- protected $indexable;
-
- /**
- * The WebFinger Resource.
+ * The generator of the object.
*
- * @var string
- */
- protected $webfinger;
-
- /**
- * Whether the User is discoverable.
- *
- * @see https://docs.joinmastodon.org/spec/activitypub/#discoverable
- *
- * @context http://joinmastodon.org/ns#discoverable
- *
- * @var boolean
- */
- protected $discoverable;
-
- /**
- * Restrict posting to mods.
+ * @see https://www.w3.org/TR/activitypub/#generator
+ * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md#discovery-through-an-actor
*
- * @see https://join-lemmy.org/docs/contributors/05-federation.html
- *
- * @var boolean
+ * @var array
*/
- protected $posting_restricted_to_mods;
+ protected $generator = array(
+ 'type' => 'Application',
+ 'implements' => array(
+ 'href' => 'https://datatracker.ietf.org/doc/html/rfc9421',
+ 'name' => 'RFC-9421: HTTP Message Signatures',
+ ),
+ );
/**
* Constructor.
@@ -314,7 +269,7 @@ public function get_image() {
* @return string The published date.
*/
public function get_published() {
- $first_post = new WP_Query(
+ $first_post = new \WP_Query(
array(
'orderby' => 'date',
'order' => 'ASC',
@@ -375,7 +330,7 @@ public function get_public_key() {
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
- 'publicKeyPem' => Signature::get_public_key_for( $this->get__id() ),
+ 'publicKeyPem' => Actors::get_public_key( $this->get__id() ),
);
}
@@ -463,6 +418,15 @@ public function get_featured() {
return get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
}
+ /**
+ * Returns the Featured-Tags-API-Endpoint.
+ *
+ * @return string The Featured-Tags-Endpoint.
+ */
+ public function get_featured_tags() {
+ return get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $this->get__id() ) );
+ }
+
/**
* Returns whether the site is indexable.
*
diff --git a/includes/model/class-follower.php b/includes/model/class-follower.php
index cec0600d5..89b21fe7f 100644
--- a/includes/model/class-follower.php
+++ b/includes/model/class-follower.php
@@ -7,10 +7,12 @@
namespace Activitypub\Model;
-use WP_Error;
use Activitypub\Activity\Actor;
+use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
+use function Activitypub\extract_name_from_uri;
+
/**
* ActivityPub Follower Class.
*
@@ -42,13 +44,22 @@ class Follower extends Actor {
*/
protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
+ /**
+ * Constructor.
+ *
+ * @deprecated Use Actor instead.
+ */
+ public function __construct() {
+ \_deprecated_class( __CLASS__, 'unreleased', Actor::class );
+ }
+
/**
* Get the errors.
*
* @return mixed
*/
public function get_errors() {
- return get_post_meta( $this->_id, '_activitypub_errors', false );
+ return Actors::get_errors( $this->_id );
}
/**
@@ -57,13 +68,7 @@ public function get_errors() {
* @return bool True on success, false on failure.
*/
public function clear_errors() {
- if ( ! $this->_id ) {
- \_doing_it_wrong( __METHOD__, 'Follower ID is not set.', 'unreleased' );
-
- return false;
- }
-
- return Followers::clear_errors( $this->_id );
+ return Actors::clear_errors( $this->_id );
}
/**
@@ -97,9 +102,11 @@ public function get_url() {
/**
* Reset (delete) all errors.
+ *
+ * @return bool True on success, false on failure.
*/
public function reset_errors() {
- delete_post_meta( $this->_id, '_activitypub_errors' );
+ return Actors::clear_errors( $this->_id );
}
/**
@@ -108,13 +115,7 @@ public function reset_errors() {
* @return int The number of errors.
*/
public function count_errors() {
- $errors = $this->get_errors();
-
- if ( is_array( $errors ) && ! empty( $errors ) ) {
- return count( $errors );
- }
-
- return 0;
+ return Actors::count_errors( $this->_id );
}
/**
@@ -125,8 +126,8 @@ public function count_errors() {
public function get_latest_error_message() {
$errors = $this->get_errors();
- if ( is_array( $errors ) && ! empty( $errors ) ) {
- return reset( $errors );
+ if ( \is_array( $errors ) && ! empty( $errors ) ) {
+ return \reset( $errors );
}
return '';
@@ -166,61 +167,26 @@ public function is_valid() {
/**
* Save the current Follower object.
*
- * @return int|WP_Error The post ID or an WP_Error.
+ * @return int|\WP_Error The post ID or an WP_Error.
*/
public function save() {
if ( ! $this->is_valid() ) {
- return new WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
+ return new \WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) );
}
- if ( ! $this->get__id() ) {
- global $wpdb;
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery
- $post_id = $wpdb->get_var(
- $wpdb->prepare(
- "SELECT ID FROM $wpdb->posts WHERE guid=%s",
- esc_sql( $this->get_id() )
- )
- );
-
- if ( $post_id ) {
- $post = get_post( $post_id );
- $this->set__id( $post->ID );
- }
- }
-
- $post_id = $this->get__id();
-
- $args = array(
- 'ID' => $post_id,
- 'guid' => esc_url_raw( $this->get_id() ),
- 'post_title' => wp_strip_all_tags( sanitize_text_field( $this->get_name() ) ),
- 'post_author' => 0,
- 'post_type' => Followers::POST_TYPE,
- 'post_name' => esc_url_raw( $this->get_id() ),
- 'post_excerpt' => sanitize_text_field( wp_kses( $this->get_summary(), 'user_description' ) ),
- 'post_status' => 'publish',
- 'meta_input' => $this->get_post_meta_input(),
- );
-
- if ( ! empty( $post_id ) ) {
- // If this is an update, prevent the "followed" date from being overwritten by the current date.
- $post = get_post( $post_id );
- $args['post_date'] = $post->post_date;
- $args['post_date_gmt'] = $post->post_date_gmt;
+ $id = Actors::upsert( $this );
+ if ( \is_wp_error( $id ) ) {
+ return $id;
}
- $post_id = wp_insert_post( $args );
- $this->_id = $post_id;
-
- return $post_id;
+ $this->set__id( $id );
+ return $id;
}
/**
* Upsert the current Follower object.
*
- * @return int|WP_Error The post ID or an WP_Error.
+ * @return int|\WP_Error The post ID or an WP_Error.
*/
public function upsert() {
return $this->save();
@@ -236,18 +202,7 @@ public function upsert() {
* @see \Activitypub\Rest\Followers::remove_follower()
*/
public function delete() {
- wp_delete_post( $this->_id );
- }
-
- /**
- * Update the post meta.
- */
- protected function get_post_meta_input() {
- $meta_input = array();
- $meta_input['_activitypub_inbox'] = $this->get_shared_inbox();
- $meta_input['_activitypub_actor_json'] = wp_slash( $this->to_json() );
-
- return $meta_input;
+ Followers::remove_follower( $this->_id, $this->get_id() );
}
/**
@@ -313,7 +268,7 @@ public function get_icon_url() {
return '';
}
- if ( is_array( $icon ) ) {
+ if ( \is_array( $icon ) ) {
return $icon['url'];
}
@@ -332,7 +287,7 @@ public function get_image_url() {
return '';
}
- if ( is_array( $image ) ) {
+ if ( \is_array( $image ) ) {
return $image['url'];
}
@@ -361,12 +316,16 @@ public function get_shared_inbox() {
* @return Follower|false The Follower object or false on failure.
*/
public static function init_from_cpt( $post ) {
- $actor_json = get_post_meta( $post->ID, '_activitypub_actor_json', true );
+ if ( empty( $post->post_content ) ) {
+ $json = \get_post_meta( $post->ID, '_activitypub_actor_json', true );
+ } else {
+ $json = $post->post_content;
+ }
/* @var Follower $object Follower object. */
- $object = self::init_from_json( $actor_json );
+ $object = self::init_from_json( $json );
- if ( is_wp_error( $object ) ) {
+ if ( \is_wp_error( $object ) ) {
return false;
}
@@ -389,39 +348,11 @@ public static function init_from_cpt( $post ) {
protected function extract_name_from_uri() {
// prefer the URL, but fall back to the ID.
if ( $this->url ) {
- $name = $this->url;
+ $uri = $this->url;
} else {
- $name = $this->id;
- }
-
- if ( \filter_var( $name, FILTER_VALIDATE_URL ) ) {
- $name = \rtrim( $name, '/' );
- $path = \wp_parse_url( $name, PHP_URL_PATH );
-
- if ( $path ) {
- if ( \strpos( $name, '@' ) !== false ) {
- // Expected: https://example.com/@user (default URL pattern).
- $name = \preg_replace( '|^/@?|', '', $path );
- } else {
- // Expected: https://example.com/users/user (default ID pattern).
- $parts = \explode( '/', $path );
- $name = \array_pop( $parts );
- }
- }
- } elseif (
- \is_email( $name ) ||
- \strpos( $name, 'acct' ) === 0 ||
- \strpos( $name, '@' ) === 0
- ) {
- // Expected: user@example.com or acct:user@example (WebFinger).
- $name = \ltrim( $name, '@' );
- if ( str_starts_with( $name, 'acct:' ) ) {
- $name = \substr( $name, 5 );
- }
- $parts = \explode( '@', $name );
- $name = $parts[0];
+ $uri = $this->id;
}
- return $name;
+ return extract_name_from_uri( $uri );
}
}
diff --git a/includes/model/class-user.php b/includes/model/class-user.php
index db37928b9..0facb74d3 100644
--- a/includes/model/class-user.php
+++ b/includes/model/class-user.php
@@ -8,8 +8,8 @@
namespace Activitypub\Model;
use Activitypub\Activity\Actor;
+use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
-use Activitypub\Signature;
use function Activitypub\is_blog_public;
use function Activitypub\get_rest_url_by_path;
@@ -29,20 +29,6 @@ class User extends Actor {
*/
protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
- /**
- * The Featured-Posts.
- *
- * @see https://docs.joinmastodon.org/spec/activitypub/#featured
- *
- * @context {
- * "@id": "http://joinmastodon.org/ns#featured",
- * "@type": "@id"
- * }
- *
- * @var string
- */
- protected $featured;
-
/**
* Whether the User is discoverable.
*
@@ -55,20 +41,20 @@ class User extends Actor {
protected $discoverable = true;
/**
- * Whether the User is indexable.
+ * The generator of the object.
*
- * @context http://joinmastodon.org/ns#indexable
- *
- * @var boolean
- */
- protected $indexable;
-
- /**
- * The WebFinger Resource.
+ * @see https://www.w3.org/TR/activitypub/#generator
+ * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md#discovery-through-an-actor
*
- * @var string
+ * @var array
*/
- protected $webfinger;
+ protected $generator = array(
+ 'type' => 'Application',
+ 'implements' => array(
+ 'href' => 'https://datatracker.ietf.org/doc/html/rfc9421',
+ 'name' => 'RFC-9421: HTTP Message Signatures',
+ ),
+ );
/**
* Constructor.
@@ -258,7 +244,7 @@ public function get_public_key() {
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
- 'publicKeyPem' => Signature::get_public_key_for( $this->get__id() ),
+ 'publicKeyPem' => Actors::get_public_key( $this->get__id() ),
);
}
@@ -307,6 +293,15 @@ public function get_featured() {
return get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $this->get__id() ) );
}
+ /**
+ * Returns the Featured-Tags-API-Endpoint.
+ *
+ * @return string The Featured-Tags-Endpoint.
+ */
+ public function get_featured_tags() {
+ return get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $this->get__id() ) );
+ }
+
/**
* Returns the endpoints.
*
diff --git a/includes/rest/class-actors-controller.php b/includes/rest/class-actors-controller.php
index ba3afc5f2..959fb8ec2 100644
--- a/includes/rest/class-actors-controller.php
+++ b/includes/rest/class-actors-controller.php
@@ -349,6 +349,28 @@ public function get_item_schema() {
'type' => 'boolean',
'readonly' => true,
),
+ 'generator' => array(
+ 'description' => 'The generator of the object.',
+ 'type' => 'object',
+ 'properties' => array(
+ 'type' => array(
+ 'type' => 'string',
+ ),
+ 'implements' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'href' => array(
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'name' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
+ 'readonly' => true,
+ ),
),
);
diff --git a/includes/rest/class-application-controller.php b/includes/rest/class-application-controller.php
index ad3c1b80c..f577437c9 100644
--- a/includes/rest/class-application-controller.php
+++ b/includes/rest/class-application-controller.php
@@ -157,6 +157,21 @@ public function get_item_schema() {
'indexable' => array(
'type' => 'boolean',
),
+ 'implements' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'href' => array(
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'name' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
'webfinger' => array(
'type' => 'string',
),
diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php
index 1f09ad453..d398391c9 100644
--- a/includes/rest/class-followers-controller.php
+++ b/includes/rest/class-followers-controller.php
@@ -112,9 +112,9 @@ public function get_items( $request ) {
'orderedItems' => array_map(
function ( $item ) use ( $context ) {
if ( 'full' === $context ) {
- return $item->to_array( false );
+ return Actors::get_actor( $item )->to_array( false );
}
- return $item->get_id();
+ return $item->guid;
},
$data['followers']
),
diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php
index 19c9aedee..f11d11857 100644
--- a/includes/rest/class-following-controller.php
+++ b/includes/rest/class-following-controller.php
@@ -8,6 +8,7 @@
namespace Activitypub\Rest;
use Activitypub\Collection\Actors;
+use Activitypub\Collection\Following;
use function Activitypub\get_context;
use function Activitypub\is_single_user;
@@ -24,13 +25,6 @@
class Following_Controller extends Actors_Controller {
use Collection;
- /**
- * Initialize the class, registering WordPress hooks.
- */
- public function __construct() {
- \add_filter( 'activitypub_rest_following', array( self::class, 'default_following' ), 10, 2 );
- }
-
/**
* Register routes.
*/
@@ -65,6 +59,18 @@ public function register_routes() {
'minimum' => 1,
'maximum' => 100,
),
+ 'order' => array(
+ 'description' => 'Order sort attribute ascending or descending.',
+ 'type' => 'string',
+ 'default' => 'desc',
+ 'enum' => array( 'asc', 'desc' ),
+ ),
+ 'context' => array(
+ 'description' => 'The context in which the request is made.',
+ 'type' => 'string',
+ 'default' => 'simple',
+ 'enum' => array( 'simple', 'full' ),
+ ),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
@@ -91,24 +97,45 @@ public function get_items( $request ) {
*/
\do_action( 'activitypub_rest_following_pre' );
+ $order = $request->get_param( 'order' );
+ $per_page = $request->get_param( 'per_page' );
+ $page = $request->get_param( 'page' ) ?? 1;
+ $context = $request->get_param( 'context' );
+
+ $data = Following::get_following_with_count( $user_id, $per_page, $page, array( 'order' => \ucwords( $order ) ) );
+
$response = array(
- '@context' => get_context(),
- 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/following', $user->get__id() ) ),
- 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
- 'actor' => $user->get_id(),
- 'type' => 'OrderedCollection',
+ '@context' => get_context(),
+ 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/following', $user->get__id() ) ),
+ 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
+ 'actor' => $user->get_id(),
+ 'type' => 'OrderedCollection',
+ 'totalItems' => $data['total'],
+ 'orderedItems' => array_map(
+ function ( $item ) use ( $context ) {
+ if ( 'full' === $context ) {
+ return Actors::get_actor( $item )->to_array( false );
+ }
+ return $item->guid;
+ },
+ $data['following']
+ ),
);
/**
- * Filter the list of following urls.
+ * Filter the list of following urls
*
* @param array $items The array of following urls.
* @param \Activitypub\Model\User $user The user object.
+ *
+ * @deprecated unreleased Please migrate your Followings to the new internal Following structure.
*/
- $items = \apply_filters( 'activitypub_rest_following', array(), $user );
+ $items = \apply_filters_deprecated( 'activitypub_rest_following', array( array(), $user ), 'unreleased', 'Please migrate your Followings to the new internal Following structure.' );
- $response['totalItems'] = \is_countable( $items ) ? \count( $items ) : 0;
- $response['orderedItems'] = $items;
+ if ( ! empty( $items ) ) {
+ $response['totalItems'] = count( $items );
+ $response['orderedItems'] = $items;
+ }
$response = $this->prepare_collection_response( $response, $request );
if ( is_wp_error( $response ) ) {
@@ -121,29 +148,6 @@ public function get_items( $request ) {
return $response;
}
- /**
- * Add the Blog Authors to the following list of the Blog Actor
- * if Blog not in single mode.
- *
- * @param array $follow_list The array of following urls.
- * @param \Activitypub\Model\User $user The user object.
- *
- * @return array The array of following urls.
- */
- public static function default_following( $follow_list, $user ) {
- if ( 0 !== $user->get__id() || is_single_user() ) {
- return $follow_list;
- }
-
- $users = Actors::get_collection();
-
- foreach ( $users as $user ) {
- $follow_list[] = $user->get_id();
- }
-
- return $follow_list;
- }
-
/**
* Retrieves the following schema, conforming to JSON Schema.
*
@@ -154,9 +158,65 @@ public function get_item_schema() {
return $this->add_additional_fields_schema( $this->schema );
}
+ // Define the schema for items in the following collection.
$item_schema = array(
- 'type' => 'string',
- 'format' => 'uri',
+ 'oneOf' => array(
+ array(
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ ),
+ 'name' => array(
+ 'type' => 'string',
+ ),
+ 'icon' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'type' => array(
+ 'type' => 'string',
+ ),
+ 'mediaType' => array(
+ 'type' => 'string',
+ ),
+ 'url' => array(
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ ),
+ ),
+ 'published' => array(
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ),
+ 'summary' => array(
+ 'type' => 'string',
+ ),
+ 'updated' => array(
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ),
+ 'url' => array(
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'streams' => array(
+ 'type' => 'array',
+ ),
+ 'preferredUsername' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
);
$schema = $this->get_collection_schema( $item_schema );
diff --git a/includes/rest/class-interaction-controller.php b/includes/rest/class-interaction-controller.php
index f4ba270a4..ef95afe88 100644
--- a/includes/rest/class-interaction-controller.php
+++ b/includes/rest/class-interaction-controller.php
@@ -41,10 +41,10 @@ public function register_routes() {
'permission_callback' => '__return_true',
'args' => array(
'uri' => array(
- 'description' => 'The URI of the object to interact with.',
- 'type' => 'string',
- 'format' => 'uri',
- 'required' => true,
+ 'description' => 'The URI or webfinger ID of the object to interact with.',
+ 'type' => 'string',
+ 'required' => true,
+ 'sanitize_callback' => array( $this, 'sanitize_uri' ),
),
),
),
@@ -52,6 +52,29 @@ public function register_routes() {
);
}
+ /**
+ * Sanitize the URI parameter.
+ *
+ * @param string $uri The URI or webfinger ID of the object to interact with.
+ *
+ * @return string Sanitized URI.
+ */
+ public function sanitize_uri( $uri ) {
+ // Remove "acct:" prefix if present.
+ if ( str_starts_with( $uri, 'acct:' ) ) {
+ $uri = \substr( $uri, 5 );
+ }
+
+ // Remove "@" prefix if present.
+ $uri = \ltrim( $uri, '@' );
+
+ if ( is_email( $uri ) ) {
+ return \sanitize_text_field( $uri );
+ }
+
+ return \sanitize_url( $uri );
+ }
+
/**
* Retrieves the interaction URL for a given URI.
*
@@ -134,6 +157,6 @@ public function get_item( $request ) {
);
}
- return new \WP_REST_Response( null, 302, array( 'Location' => \esc_url( $redirect_url ) ) );
+ return new \WP_REST_Response( null, 302, array( 'Location' => $redirect_url ) );
}
}
diff --git a/includes/rest/class-post-controller.php b/includes/rest/class-post-controller.php
index 459ebbc45..bb7cbe8b8 100644
--- a/includes/rest/class-post-controller.php
+++ b/includes/rest/class-post-controller.php
@@ -98,6 +98,7 @@ public function get_reactions( $request ) {
'post_id' => $post_id,
'type' => $type_object['type'],
'status' => 'approve',
+ 'parent' => 0,
)
);
@@ -123,9 +124,9 @@ public function get_reactions( $request ) {
'items' => \array_map(
function ( $comment ) {
return array(
- 'name' => $comment->comment_author,
+ 'name' => html_entity_decode( $comment->comment_author ),
'url' => $comment->comment_author_url,
- 'avatar' => \get_comment_meta( $comment->comment_ID, 'avatar_url', true ),
+ 'avatar' => \get_avatar_url( $comment ),
);
},
$comments
diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php
index 8ae694f3e..408f5ad17 100644
--- a/includes/scheduler/class-post.php
+++ b/includes/scheduler/class-post.php
@@ -55,7 +55,11 @@ public static function schedule_post_activity( $post_id, $post, $update, $post_b
switch ( $new_status ) {
case 'publish':
- $type = ( 'publish' === $old_status ) ? 'Update' : 'Create';
+ if ( $update ) {
+ $type = ( 'publish' === $old_status ) ? 'Update' : 'Create';
+ } else {
+ $type = 'Create';
+ }
break;
case 'draft':
@@ -75,6 +79,11 @@ public static function schedule_post_activity( $post_id, $post, $update, $post_b
return;
}
+ // If the post was not federated before but is an Update activity, it should be a Create activity.
+ if ( get_wp_object_state( $post ) !== 'federated' && 'Update' === $type ) {
+ $type = 'Create';
+ }
+
// Add the post to the outbox.
add_to_outbox( $post, $type, $post->post_author );
}
diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php
new file mode 100644
index 000000000..ec3007fbd
--- /dev/null
+++ b/includes/signature/class-http-message-signature.php
@@ -0,0 +1,456 @@
+ array(
+ 'type' => OPENSSL_KEYTYPE_RSA,
+ 'algo' => OPENSSL_ALGO_SHA256,
+ ),
+ 'rsa-v1_5-sha384' => array(
+ 'type' => OPENSSL_KEYTYPE_RSA,
+ 'algo' => OPENSSL_ALGO_SHA384,
+ ),
+ 'rsa-v1_5-sha512' => array(
+ 'type' => OPENSSL_KEYTYPE_RSA,
+ 'algo' => OPENSSL_ALGO_SHA512,
+ ),
+
+ // RSA PSS (note: not supported in openssl_verify() until PHP 8.1).
+ 'rsa-pss-sha256' => array(
+ 'type' => OPENSSL_KEYTYPE_RSA,
+ 'algo' => OPENSSL_ALGO_SHA256,
+ ),
+ 'rsa-pss-sha384' => array(
+ 'type' => OPENSSL_KEYTYPE_RSA,
+ 'algo' => OPENSSL_ALGO_SHA384,
+ ),
+ 'rsa-pss-sha512' => array(
+ 'type' => OPENSSL_KEYTYPE_RSA,
+ 'algo' => OPENSSL_ALGO_SHA512,
+ ),
+
+ // ECDSA.
+ 'ecdsa-p256-sha256' => array(
+ 'type' => OPENSSL_KEYTYPE_EC,
+ 'algo' => OPENSSL_ALGO_SHA256,
+ ),
+ 'ecdsa-p384-sha384' => array(
+ 'type' => OPENSSL_KEYTYPE_EC,
+ 'algo' => OPENSSL_ALGO_SHA384,
+ ),
+ 'ecdsa-p521-sha512' => array(
+ 'type' => OPENSSL_KEYTYPE_EC,
+ 'algo' => OPENSSL_ALGO_SHA512,
+ ),
+ );
+
+ /**
+ * Digest algorithms.
+ *
+ * @var string[]
+ */
+ private $digest_algorithms = array(
+ 'sha-256' => 'sha256',
+ 'sha-512' => 'sha512',
+ );
+
+ /**
+ * Generate RFC-9421 compliant Signature-Input and Signature headers for an outgoing HTTP request.
+ *
+ * @param array $args The request arguments.
+ * @param string $url The request URL.
+ *
+ * @return array Request arguments with signature headers.
+ */
+ public function sign( $args, $url ) {
+ // Standard components to sign.
+ $components = array(
+ '"@method"' => \strtoupper( $args['method'] ),
+ '"@target-uri"' => $url,
+ '"@authority"' => \wp_parse_url( $url, PHP_URL_HOST ),
+ );
+ $identifiers = \array_keys( $components );
+
+ // Add digest if provided.
+ if ( isset( $args['body'] ) ) {
+ $components['"content-digest"'] = $this->generate_digest( $args['body'] );
+ $identifiers = \array_keys( $components );
+
+ $args['headers']['Content-Digest'] = $components['"content-digest"'];
+ }
+
+ $params = array(
+ 'created' => \strtotime( $args['headers']['Date'] ),
+ 'keyid' => $args['key_id'],
+ 'alg' => 'rsa-v1_5-sha256',
+ );
+
+ // Build the signature base string as per RFC-9421.
+ $signature_base = $this->get_signature_base_string( $components, $params );
+
+ $signature = null;
+ \openssl_sign( $signature_base, $signature, $args['private_key'], \OPENSSL_ALGO_SHA256 );
+ $signature = \base64_encode( $signature );
+
+ $args['headers']['Signature-Input'] = 'wp=(' . \implode( ' ', $identifiers ) . ')' . $this->get_params_string( $params );
+ $args['headers']['Signature'] = 'wp=:' . $signature . ':';
+
+ return $args;
+ }
+
+ /**
+ * Verify the HTTP Signature against a request.
+ *
+ * @param array $headers The HTTP headers.
+ * @param string|null $body The request body, if applicable.
+ * @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
+ */
+ public function verify( array $headers, $body = null ) {
+ $parsed = $this->parse_signature_labels( $headers );
+ if ( \is_wp_error( $parsed ) ) {
+ return $parsed;
+ }
+
+ $errors = new \WP_Error();
+ foreach ( $parsed as $data ) {
+ $result = $this->verify_signature_label( $data, $headers, $body );
+ if ( true === $result ) {
+ return true;
+ }
+
+ if ( \is_wp_error( $result ) ) {
+ $errors->add( $result->get_error_code(), $result->get_error_message() );
+ }
+ }
+
+ // No valid signature found.
+ $errors->add_data( array( 'status' => 401 ) );
+
+ return $errors;
+ }
+
+ /**
+ * Generate a digest for the request body.
+ *
+ * @param string $body The request body.
+ *
+ * @return string The digest.
+ */
+ public function generate_digest( $body ) {
+ return 'sha-256=:' . \base64_encode( \hash( 'sha256', $body, true ) ) . ':';
+ }
+
+ /**
+ * Parse the Signature-Input and Signature headers.
+ *
+ * @param array $headers The HTTP headers.
+ * @return array|\WP_Error Parsed signature labels or WP_Error on failure.
+ */
+ private function parse_signature_labels( array $headers ) {
+ $parsed_inputs = array();
+ \preg_match_all( '/(?P\w+)=\((?P[^)]*)\)(?P[^,]*)/', $headers['signature_input'][0], $matches, PREG_SET_ORDER );
+
+ foreach ( $matches as $match ) {
+ $label = $match['label'];
+ $components = \preg_split( '/\s+/', \trim( $match['components'] ) );
+ $param_str = \trim( $match['params'], '; ' );
+ $params = array();
+
+ foreach ( \explode( ';', $param_str ) as $param ) {
+ if ( \preg_match( '/(\w+)=("?)([^";]+)\2/', \trim( $param ), $m ) ) {
+ $params[ \strtolower( $m[1] ) ] = $m[3];
+ }
+ }
+
+ if ( \preg_match( '/' . \preg_quote( $label, '/' ) . '=:([^:]+):/', $headers['signature'][0], $sig_match ) ) {
+ $parsed_inputs[ $label ] = array(
+ 'components' => $components,
+ 'params' => $params,
+ 'signature' => \base64_decode( $sig_match[1] ),
+ );
+ }
+ }
+
+ if ( empty( $parsed_inputs ) ) {
+ return new \WP_Error( 'no_valid_labels', 'No valid signature labels found.' );
+ }
+
+ return $parsed_inputs;
+ }
+
+ /**
+ * Verify a single signature label.
+ *
+ * @param array $data Parsed signature data.
+ * @param array $headers HTTP headers.
+ * @param string|null $body Request body, if applicable.
+ * @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
+ */
+ private function verify_signature_label( $data, $headers, $body ) {
+ $params = $data['params'];
+
+ // Timestamp verification.
+ if ( isset( $params['created'] ) && (int) $params['created'] > \time() + MINUTE_IN_SECONDS ) {
+ return new \WP_Error( 'invalid_created', 'The signature creation time is in the future.' );
+ }
+ if ( isset( $params['expires'] ) && (int) $params['expires'] < \time() ) {
+ return new \WP_Error( 'expired_signature', 'The signature has expired.' );
+ }
+
+ // KeyId verification.
+ if ( empty( $params['keyid'] ) ) {
+ return new \WP_Error( 'missing_keyid', 'Missing keyId in signature parameters.' );
+ }
+
+ $public_key = Actors::get_remote_key( $params['keyid'] );
+ if ( \is_wp_error( $public_key ) ) {
+ return $public_key;
+ }
+
+ // Algorithm verification.
+ $algorithm = $this->verify_algorithm( $params['alg'] ?? '', $public_key );
+ if ( \is_wp_error( $algorithm ) ) {
+ return $algorithm;
+ }
+
+ // Digest verification.
+ $result = $this->verify_content_digest( $headers, $body );
+ if ( \is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $components = $this->get_component_values( $data['components'], $headers );
+ $signature_base = $this->get_signature_base_string( $components, $params );
+
+ $verified = \openssl_verify( $signature_base, $data['signature'], $public_key, $algorithm ) > 0;
+ if ( ! $verified ) {
+ return new \WP_Error( 'activitypub_signature', 'Invalid signature' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Verify the Content-Digest header against the request body.
+ *
+ * @param array $headers The HTTP headers.
+ * @param string|null $body The request body, if applicable.
+ * @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
+ */
+ private function verify_content_digest( $headers, $body ) {
+ if ( ! isset( $headers['content_digest'][0] ) || null === $body ) {
+ return true;
+ }
+
+ $digests = \array_map( 'trim', \explode( ',', $headers['content_digest'][0] ) );
+
+ foreach ( $digests as $digest ) {
+ if ( \preg_match( '/^([a-z0-9-]+)=:(.+):$/i', $digest, $matches ) ) {
+ list( , $alg, $encoded ) = $matches;
+
+ if ( ! isset( $this->digest_algorithms[ $alg ] ) ) {
+ return new \WP_Error( 'unsupported_digest', 'WordPress supports sha-256 and sha-512 in Digest header. Offered algorithm: ' . $alg );
+ }
+
+ if ( \hash_equals( $encoded, \base64_encode( \hash( $this->digest_algorithms[ $alg ], $body, true ) ) ) ) {
+ return true;
+ }
+ }
+ }
+
+ return new \WP_Error( 'digest_mismatch', 'Content-Digest header value does not match body.' );
+ }
+
+ /**
+ * Resolve and validate the HTTP Signature algorithm from `alg=` parameter and key.
+ *
+ * @param string $alg_string The alg= parameter value (e.g., 'rsa-pss-sha512').
+ * @param resource $public_key An OpenSSL public key resource.
+ *
+ * @return int|\WP_Error OpenSSL algorithm constant or WP_Error.
+ */
+ private function verify_algorithm( $alg_string, $public_key ) {
+ $details = \openssl_pkey_get_details( $public_key );
+ if ( ! isset( $details['type'] ) ) {
+ return new \WP_Error( 'invalid_key_details', 'Unable to read public key details.' );
+ }
+
+ // If alg_string is empty, determine algorithm based on public key.
+ if ( empty( $alg_string ) ) {
+ switch ( $details['type'] ) {
+ case \OPENSSL_KEYTYPE_RSA:
+ $bits = $details['bits'] ?? 2048;
+
+ if ( $bits >= 4 * KB_IN_BYTES ) {
+ return \OPENSSL_ALGO_SHA512;
+ } elseif ( $bits >= 3 * KB_IN_BYTES ) {
+ return \OPENSSL_ALGO_SHA384;
+ } else {
+ return \OPENSSL_ALGO_SHA256;
+ }
+
+ case \OPENSSL_KEYTYPE_EC:
+ switch ( $details['ec']['curve_name'] ?? '' ) {
+ case 'prime256v1':
+ case 'secp256r1':
+ return \OPENSSL_ALGO_SHA256;
+ case 'secp384r1':
+ return \OPENSSL_ALGO_SHA384;
+ case 'secp521r1':
+ return \OPENSSL_ALGO_SHA512;
+ }
+ }
+ }
+
+ $alg_string = \strtolower( $alg_string );
+ if ( \strpos( $alg_string, 'rsa-pss-' ) === 0 && \version_compare( PHP_VERSION, '8.1.0', '<' ) ) {
+ return new \WP_Error( 'unsupported_pss', 'RSA-PSS algorithms are not supported.' );
+ }
+
+ if ( ! isset( $this->algorithms[ $alg_string ] ) ) {
+ return new \WP_Error( 'unsupported_alg', 'Unsupported or unknown alg parameter: ' . $alg_string );
+ }
+
+ if ( $this->algorithms[ $alg_string ]['type'] !== $details['type'] ) {
+ return new \WP_Error( 'alg_key_mismatch', 'Algorithm does not match public key type.' );
+ }
+
+ return $this->algorithms[ $alg_string ]['algo'];
+ }
+
+ /**
+ * Returns the base strings to compare the incoming signature with.
+ *
+ * @param array $components Signature components.
+ * @param array $params Signature params.
+ *
+ * @return string Base string to compare signature with.
+ */
+ private function get_signature_base_string( $components, $params ) {
+ $signature_base = '';
+
+ foreach ( $components as $component => $value ) {
+ $signature_base .= $component . ': ' . $value . "\n";
+ }
+
+ $signature_base .= '"@signature-params": (' . \implode( ' ', \array_keys( $components ) ) . ')';
+ $signature_base .= $this->get_params_string( $params );
+
+ return $signature_base;
+ }
+
+ /**
+ * Returns the signature params in a string format.
+ *
+ * @param array $params Signature params.
+ *
+ * @return string Signature params.
+ */
+ private function get_params_string( $params ) {
+ $signature_params = '';
+
+ foreach ( $params as $key => $value ) {
+ if ( \is_numeric( $value ) ) {
+ $signature_params .= ';' . $key . '=' . $value; // No quotes.
+ } else {
+ // Escape backslashes and double quotes per RFC-9421.
+ $value = \str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
+ $signature_params .= ';' . $key . '="' . $value . '"'; // Double quotes.
+ }
+ }
+
+ return $signature_params;
+ }
+
+ /**
+ * Generate signature components.
+ *
+ * @param array $components Signature component names.
+ * @param array $headers HTTP headers.
+ *
+ * @return array Signature components.
+ */
+ private function get_component_values( $components, $headers ) {
+ $signature_components = array();
+
+ foreach ( $components as $component ) {
+ $key = \strtok( $component, ';' ); // See https://www.rfc-editor.org/rfc/rfc9421.html#name-query-parameters.
+ $key = \strtolower( \trim( $key, '"' ) );
+
+ switch ( $key ) {
+ case '@method':
+ $value = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+ break;
+
+ case '@target-uri':
+ $value = \set_url_scheme( '//' . ( $_SERVER['HTTP_HOST'] ?? '' ) . ( $_SERVER['REQUEST_URI'] ?? '/' ) );
+ break;
+
+ case '@authority':
+ $value = $_SERVER['HTTP_HOST'] ?? '';
+ break;
+
+ case '@scheme':
+ $value = \is_ssl() ? 'https' : 'http';
+ break;
+
+ case '@request-target':
+ $value = $_SERVER['REQUEST_URI'] ?? '/';
+ break;
+
+ case '@path':
+ $value = \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH );
+ break;
+
+ case '@query':
+ $value = \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY );
+ $value = $value ? '?' . $value : '';
+ break;
+
+ case '@query-param':
+ $value = '';
+ if ( \preg_match( '/"@query-param";name="(?P[^"]+)"/', $component, $matches ) ) {
+ $query = \wp_parse_args( \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY ) );
+ $value = $query[ $matches['name'] ] ?? '';
+ }
+ break;
+
+ default:
+ /** Canonicalize header names. {@see WP_REST_Request::canonicalize_header_name()} */
+ $key = \str_replace( '-', '_', $key );
+ $value = \preg_replace( '/\s+/', ' ', \trim( $headers[ $key ][0] ?? '' ) );
+ }
+
+ $signature_components[ $component ] = $value;
+ }
+
+ return $signature_components;
+ }
+}
diff --git a/includes/signature/class-http-signature-draft.php b/includes/signature/class-http-signature-draft.php
new file mode 100644
index 000000000..eab5928a6
--- /dev/null
+++ b/includes/signature/class-http-signature-draft.php
@@ -0,0 +1,329 @@
+generate_digest( $args['body'] );
+
+ $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: {$args['headers']['Digest']}";
+ $headers_list = '(request-target) host date digest';
+ } else {
+ $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date";
+ $headers_list = '(request-target) host date';
+ }
+
+ $signature = null;
+ \openssl_sign( $signed_string, $signature, $args['private_key'], \OPENSSL_ALGO_SHA256 );
+ $signature = \base64_encode( $signature );
+
+ $args['headers']['Signature'] = \sprintf(
+ 'keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"',
+ $args['key_id'],
+ $headers_list,
+ $signature
+ );
+
+ return $args;
+ }
+
+ /**
+ * Verify the HTTP Signature against a request.
+ *
+ * @param array $headers The HTTP headers.
+ * @param string|null $body The request body, if applicable.
+ * @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
+ */
+ public function verify( array $headers, $body = null ) {
+ if ( ! isset( $headers['signature'] ) && ! isset( $headers['authorization'] ) ) {
+ return new \WP_Error( 'missing_signature', 'No Signature or Authorization header present.' );
+ }
+
+ $header = $headers['signature'] ?? $headers['authorization'];
+ $parsed = $this->parse_signature_header( $header[0] );
+
+ if ( empty( $parsed['keyId'] ) ) {
+ return new \WP_Error( 'activitypub_signature', 'No Key ID present.' );
+ }
+
+ $public_key = Actors::get_remote_key( $parsed['keyId'] );
+ if ( \is_wp_error( $public_key ) ) {
+ return $public_key;
+ }
+
+ $signed_data = $this->get_signed_data( $parsed['headers'], $parsed, $headers );
+ if ( ! $signed_data ) {
+ return new \WP_Error( 'invalid_signed_data', 'Signed data is invalid or expired.' );
+ }
+
+ $algorithm = $this->get_signature_algorithm( $parsed, $public_key );
+ if ( \is_wp_error( $algorithm ) ) {
+ return $algorithm;
+ }
+
+ // Digest verification.
+ $result = $this->verify_content_digest( $headers, $body );
+ if ( \is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $verified = \openssl_verify( $signed_data, $parsed['signature'], $public_key, $algorithm ) > 0;
+ if ( ! $verified ) {
+ return new \WP_Error( 'activitypub_signature', 'Invalid signature', array( 'status' => 401 ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * Generates the digest for an HTTP Request.
+ *
+ * @param string $body The body of the request.
+ *
+ * @return string The digest.
+ */
+ public function generate_digest( $body ) {
+ return 'SHA-256=' . \base64_encode( \hash( 'sha256', $body, true ) );
+ }
+
+ /**
+ * Gets the signature algorithm from the signature header.
+ *
+ * @param array $signature_block The signature block.
+ * @param resource $public_key The public key resource.
+ *
+ * @return int|\WP_Error The signature algorithm or WP_Error if not found.
+ */
+ private function get_signature_algorithm( $signature_block, $public_key ) {
+ if ( ! empty( $signature_block['algorithm'] ) ) {
+ switch ( $signature_block['algorithm'] ) {
+ case 'hs2019':
+ $details = \openssl_pkey_get_details( $public_key );
+
+ switch ( $details['type'] ?? 0 ) {
+ case \OPENSSL_KEYTYPE_RSA:
+ $bits = $details['bits'] ?? 2048;
+
+ if ( $bits >= 4 * KB_IN_BYTES ) {
+ return \OPENSSL_ALGO_SHA512;
+ } elseif ( $bits >= 3 * KB_IN_BYTES ) {
+ return \OPENSSL_ALGO_SHA384;
+ } else {
+ return \OPENSSL_ALGO_SHA256;
+ }
+
+ case \OPENSSL_KEYTYPE_EC:
+ $curve_name = $details['ec']['curve_name'] ?? '';
+
+ // 3 levels switch statements are fine, right?
+ switch ( $curve_name ) {
+ case 'prime256v1':
+ case 'secp256r1':
+ return \OPENSSL_ALGO_SHA256;
+ case 'secp384r1':
+ return \OPENSSL_ALGO_SHA384;
+ case 'secp521r1':
+ return \OPENSSL_ALGO_SHA512;
+ }
+ }
+
+ return new \WP_Error( 'unsupported_key_type', 'Unsupported key type (only RSA and EC keys are supported).', array( 'status' => 401 ) );
+
+ case 'rsa-sha512':
+ return \OPENSSL_ALGO_SHA512;
+ default:
+ return \OPENSSL_ALGO_SHA256;
+ }
+ }
+
+ return new \WP_Error( 'unsupported_key_type', 'Unsupported signature algorithm (only rsa-sha256, rsa-sha512, and hs2019 are supported).', array( 'status' => 401 ) );
+ }
+
+ /**
+ * Verify the Content-Digest header against the request body.
+ *
+ * @param array $headers The HTTP headers.
+ * @param string|null $body The request body, if applicable.
+ * @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
+ */
+ private function verify_content_digest( $headers, $body ) {
+ if ( ! isset( $headers['digest'][0] ) || null === $body ) {
+ return true;
+ }
+
+ list( $alg, $digest ) = \explode( '=', $headers['digest'][0], 2 );
+ $map = array(
+ 'SHA-256' => 'sha256',
+ 'SHA-512' => 'sha512',
+ );
+
+ if ( ! isset( $map[ $alg ] ) ) {
+ return new \WP_Error( 'unsupported_digest', 'WordPress supports SHA-256 and SHA-512 in Digest header. Offered algorithm: ' . $alg, array( 'status' => 401 ) );
+ }
+
+ if ( \hash_equals( $digest, \base64_encode( \hash( $map[ $alg ], $body, true ) ) ) ) {
+ return true;
+ }
+
+ return new \WP_Error( 'digest_mismatch', 'Digest header value does not match body.', array( 'status' => 401 ) );
+ }
+
+ /**
+ * Parses the Signature header.
+ *
+ * @param string $signature The signature header.
+ *
+ * @return array Signature parts.
+ */
+ private function parse_signature_header( $signature ) {
+ $parsed_header = array();
+ $matches = array();
+
+ if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
+ $parsed_header['keyId'] = trim( $matches[1] );
+ }
+ if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/im', $signature, $matches ) ) {
+ $parsed_header['(created)'] = trim( $matches[1] );
+ }
+ if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/im', $signature, $matches ) ) {
+ $parsed_header['(expires)'] = trim( $matches[1] );
+ }
+ if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
+ $parsed_header['algorithm'] = trim( $matches[1] );
+ }
+ if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
+ $parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
+ }
+ if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
+ $parsed_header['signature'] = \base64_decode( \preg_replace( '/\s+/', '', \trim( $matches[1] ) ) );
+ }
+
+ if ( empty( $parsed_header['headers'] ) ) {
+ $parsed_header['headers'] = array( 'date' );
+ }
+
+ return $parsed_header;
+ }
+
+ /**
+ * Gets the header data from the included pseudo headers.
+ *
+ * @param array $signed_headers The signed headers.
+ * @param array $signature_block The signature block.
+ * @param array $headers The HTTP headers.
+ *
+ * @return string signed headers for comparison
+ */
+ private function get_signed_data( $signed_headers, $signature_block, $headers ) {
+ $signed_data = '';
+
+ // This also verifies time-based values by returning false if any of these are out of range.
+ foreach ( $signed_headers as $header ) {
+ if ( 'host' === $header ) {
+ if ( isset( $headers['x_original_host'] ) ) {
+ $signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
+ continue;
+ }
+ }
+ if ( '(request-target)' === $header ) {
+ $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
+ continue;
+ }
+ if ( \str_contains( $header, '-' ) ) {
+ $signed_data .= $header . ': ' . $headers[ \str_replace( '-', '_', $header ) ][0] . "\n";
+ continue;
+ }
+ if ( '(created)' === $header ) {
+ if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
+ // Created in the future.
+ return false;
+ }
+
+ if ( ! \array_key_exists( '(created)', $headers ) ) {
+ $signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
+ continue;
+ }
+ }
+ if ( '(expires)' === $header ) {
+ if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
+ // Expired in the past.
+ return false;
+ }
+
+ if ( ! \array_key_exists( '(expires)', $headers ) ) {
+ $signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
+ continue;
+ }
+ }
+ if ( 'date' === $header ) {
+ if ( empty( $headers['date'][0] ) ) {
+ continue;
+ }
+
+ // Allow a bit of leeway for misconfigured clocks.
+ $date = \date_create( $headers['date'][0] );
+ $date->setTimeZone( \timezone_open( 'UTC' ) );
+ $date = $date->format( 'U' );
+
+ $max = \time() + ( 3 * HOUR_IN_SECONDS );
+ $min = \time() - ( 3 * HOUR_IN_SECONDS );
+
+ if ( $date > $max || $date < $min ) {
+ // Time out of range.
+ return false;
+ }
+ }
+
+ if ( ! empty( $headers[ $header ][0] ) ) {
+ $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
+ }
+ }
+ return \rtrim( $signed_data, "\n" );
+ }
+}
diff --git a/includes/signature/interface-http-signature.php b/includes/signature/interface-http-signature.php
new file mode 100644
index 000000000..3a0a614f7
--- /dev/null
+++ b/includes/signature/interface-http-signature.php
@@ -0,0 +1,45 @@
+id === 'settings_page_activitypub' ) {
- $this->user_id = Actors::BLOG_USER_ID;
+ $this->user_id = Actors::BLOG_USER_ID;
+ $this->follow_url = \admin_url( 'options-general.php?page=activitypub&tab=following' );
} else {
- $this->user_id = \get_current_user_id();
+ $this->user_id = \get_current_user_id();
+ $this->follow_url = \admin_url( 'users.php?page=activitypub-following' );
+
+ \add_action( 'admin_notices', array( $this, 'process_admin_notices' ) );
}
parent::__construct(
@@ -45,6 +60,80 @@ public function __construct() {
'ajax' => false,
)
);
+
+ \add_action( 'load-' . get_current_screen()->id, array( $this, 'process_action' ), 20 );
+ }
+
+ /**
+ * Process action.
+ */
+ public function process_action() {
+ if ( ! \current_user_can( 'edit_user', $this->user_id ) ) {
+ return;
+ }
+
+ if ( ! $this->current_action() ) {
+ return;
+ }
+
+ $redirect_to = \add_query_arg(
+ array(
+ 'settings-updated' => true, // Tell WordPress to load settings errors transient.
+ 'action' => false, // Remove action parameter to prevent redirect loop.
+ )
+ );
+
+ switch ( $this->current_action() ) {
+ case 'delete':
+ $redirect_to = \remove_query_arg( array( 'follower', 'followers' ), $redirect_to );
+
+ // Handle single follower deletion.
+ if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) {
+ $follower = \absint( $_GET['follower'] );
+ $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
+
+ if ( \wp_verify_nonce( $nonce, 'delete-follower_' . $follower ) ) {
+ Follower_Collection::remove( $follower, $this->user_id );
+
+ \add_settings_error( 'activitypub', 'follower_deleted', \__( 'Follower deleted.', 'activitypub' ), 'success' );
+ }
+ }
+
+ // Handle bulk actions.
+ if ( isset( $_REQUEST['followers'], $_REQUEST['_wpnonce'] ) ) {
+ $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) );
+
+ if ( \wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
+ $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) );
+ foreach ( $followers as $follower ) {
+ Follower_Collection::remove( $follower, $this->user_id );
+ }
+
+ $count = \count( $followers );
+ /* translators: %d: Number of followers deleted. */
+ $message = \_n( '%d follower deleted.', '%d followers deleted.', $count, 'activitypub' );
+ $message = \sprintf( $message, \number_format_i18n( $count ) );
+
+ \add_settings_error( 'activitypub', 'followers_deleted', $message, 'success' );
+ }
+ }
+ break;
+
+ default:
+ break;
+ }
+
+ \set_transient( 'settings_errors', get_settings_errors(), 30 ); // 30 seconds.
+
+ \wp_safe_redirect( $redirect_to );
+ exit;
+ }
+
+ /**
+ * Process admin notices based on query parameters.
+ */
+ public function process_admin_notices() {
+ \settings_errors( 'activitypub' );
}
/**
@@ -55,12 +144,10 @@ public function __construct() {
public function get_columns() {
return array(
'cb' => ' ',
- 'post_title' => \__( 'Name', 'activitypub' ),
- 'avatar' => \__( 'Avatar', 'activitypub' ),
- 'username' => \__( 'Username', 'activitypub' ),
- 'url' => \__( 'URL', 'activitypub' ),
- 'published' => \__( 'Followed', 'activitypub' ),
- 'modified' => \__( 'Last updated', 'activitypub' ),
+ 'username' => \esc_html__( 'Username', 'activitypub' ),
+ 'post_title' => \esc_html__( 'Name', 'activitypub' ),
+ 'webfinger' => \esc_html__( 'Profile', 'activitypub' ),
+ 'modified' => \esc_html__( 'Last updated', 'activitypub' ),
);
}
@@ -71,9 +158,9 @@ public function get_columns() {
*/
public function get_sortable_columns() {
return array(
+ 'username' => array( 'username', true ),
'post_title' => array( 'post_title', true ),
'modified' => array( 'modified', false ),
- 'published' => array( 'published', false ),
);
}
@@ -81,35 +168,24 @@ public function get_sortable_columns() {
* Prepare items.
*/
public function prepare_items() {
- $columns = $this->get_columns();
- $hidden = array();
-
- $this->process_action();
- $this->_column_headers = array( $columns, $hidden, $this->get_sortable_columns() );
-
$page_num = $this->get_pagenum();
- $per_page = 20;
-
- $args = array();
+ $per_page = $this->get_items_per_page( 'activitypub_followers_per_page' );
+ $args = array();
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['orderby'] ) ) {
- $args['orderby'] = sanitize_text_field( wp_unslash( $_GET['orderby'] ) );
+ $args['orderby'] = \sanitize_text_field( \wp_unslash( $_GET['orderby'] ) );
}
if ( isset( $_GET['order'] ) ) {
- $args['order'] = sanitize_text_field( wp_unslash( $_GET['order'] ) );
+ $args['order'] = \sanitize_text_field( \wp_unslash( $_GET['order'] ) );
}
- if ( isset( $_GET['s'] ) && isset( $_REQUEST['_wpnonce'] ) ) {
- $nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) );
- if ( wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
- $args['s'] = sanitize_text_field( wp_unslash( $_GET['s'] ) );
- }
+ if ( ! empty( $_GET['s'] ) ) {
+ $args['s'] = self::normalize_search_term( \wp_unslash( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}
- // phpcs:enable WordPress.Security.NonceVerification.Recommended
- $followers_with_count = FollowerCollection::get_followers_with_count( $this->user_id, $per_page, $page_num, $args );
+ $followers_with_count = Follower_Collection::get_followers_with_count( $this->user_id, $per_page, $page_num, $args );
$followers = $followers_with_count['followers'];
$counter = $followers_with_count['total'];
@@ -123,18 +199,64 @@ public function prepare_items() {
);
foreach ( $followers as $follower ) {
- $item = array(
- 'icon' => esc_attr( $follower->get_icon_url() ),
- 'post_title' => esc_attr( $follower->get_name() ),
- 'username' => esc_attr( $follower->get_preferred_username() ),
- 'url' => esc_attr( object_to_uri( $follower->get_url() ) ),
- 'identifier' => esc_attr( $follower->get_id() ),
- 'published' => esc_attr( $follower->get_published() ),
- 'modified' => esc_attr( $follower->get_updated() ),
+ $actor = Actors::get_actor( $follower );
+
+ if ( \is_wp_error( $actor ) ) {
+ continue;
+ }
+
+ $url = object_to_uri( $actor->get_url() ?? $actor->get_id() );
+ $webfinger = Webfinger::uri_to_acct( $url );
+
+ if ( is_wp_error( $webfinger ) ) {
+ $webfinger = Webfinger::guess( $url );
+ }
+
+ $this->items[] = array(
+ 'id' => $follower->ID,
+ 'icon' => $actor->get_icon()['url'] ?? '',
+ 'post_title' => $actor->get_name() ?? $actor->get_preferred_username(),
+ 'username' => $actor->get_preferred_username(),
+ 'url' => $url,
+ 'webfinger' => $webfinger,
+ 'identifier' => $actor->get_id(),
+ 'modified' => $follower->post_modified_gmt,
);
+ }
+ }
- $this->items[] = $item;
+ /**
+ * Returns views.
+ *
+ * @return string[]
+ */
+ public function get_views() {
+ $count = Follower_Collection::count_followers( $this->user_id );
+
+ $path = 'users.php?page=activitypub-followers-list';
+ if ( Actors::BLOG_USER_ID === $this->user_id ) {
+ $path = 'options-general.php?page=activitypub&tab=followers';
}
+
+ $links = array(
+ 'all' => array(
+ 'url' => admin_url( $path ),
+ 'label' => sprintf(
+ /* translators: %s: Number of users. */
+ \_nx(
+ 'All (%s) ',
+ 'All (%s) ',
+ $count,
+ 'users',
+ 'activitypub'
+ ),
+ number_format_i18n( $count )
+ ),
+ 'current' => true,
+ ),
+ );
+
+ return $this->get_views_links( $links );
}
/**
@@ -144,7 +266,7 @@ public function prepare_items() {
*/
public function get_bulk_actions() {
return array(
- 'delete' => __( 'Delete', 'activitypub' ),
+ 'delete' => \__( 'Delete', 'activitypub' ),
);
}
@@ -157,82 +279,159 @@ public function get_bulk_actions() {
*/
public function column_default( $item, $column_name ) {
if ( ! array_key_exists( $column_name, $item ) ) {
- return __( 'None', 'activitypub' );
+ return \esc_html__( 'None', 'activitypub' );
}
- return $item[ $column_name ];
+
+ return \esc_html( $item[ $column_name ] );
}
/**
- * Column avatar.
+ * Column cb.
*
* @param array $item Item.
* @return string
*/
- public function column_avatar( $item ) {
- return sprintf(
- ' ',
- $item['icon']
+ public function column_cb( $item ) {
+ return \sprintf( ' ', \esc_attr( $item['id'] ) );
+ }
+
+ /**
+ * Column username.
+ *
+ * @param array $item Item.
+ * @return string
+ */
+ public function column_username( $item ) {
+ return \sprintf(
+ ' %4$s ',
+ \esc_url( $item['icon'] ),
+ \esc_attr( $item['username'] ),
+ \esc_url( $item['url'] ),
+ \esc_html( $item['username'] )
);
}
/**
- * Column url.
+ * Column webfinger.
*
* @param array $item Item.
* @return string
*/
- public function column_url( $item ) {
- return sprintf(
- '%s ',
- esc_url( $item['url'] ),
- $item['url']
+ public function column_webfinger( $item ) {
+ $webfinger = Sanitize::webfinger( $item['webfinger'] );
+
+ return \sprintf(
+ '@%2$s ',
+ \esc_url( $item['url'] ),
+ \esc_html( $webfinger )
);
}
/**
- * Column cb.
+ * Column modified.
*
* @param array $item Item.
* @return string
*/
- public function column_cb( $item ) {
- return sprintf( ' ', esc_attr( $item['identifier'] ) );
+ public function column_modified( $item ) {
+ $modified = \strtotime( $item['modified'] );
+ return \sprintf(
+ '%2$s ',
+ \esc_attr( \gmdate( 'c', $modified ) ),
+ \esc_html( \gmdate( \get_option( 'date_format' ), $modified ) )
+ );
}
/**
- * Process action.
+ * Message to be displayed when there are no followers.
*/
- public function process_action() {
- if ( ! isset( $_REQUEST['followers'] ) || ! isset( $_REQUEST['_wpnonce'] ) ) {
- return;
- }
- $nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) );
- if ( ! wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
- return;
+ public function no_items() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $search = \sanitize_text_field( \wp_unslash( $_GET['s'] ?? '' ) );
+ $actor_or_false = $this->_is_followable( $search );
+
+ if ( $actor_or_false ) {
+ \printf(
+ /* translators: %s: Actor name. */
+ \esc_html__( '%1$s is not following you, would you like to %2$s instead?', 'activitypub' ),
+ \esc_html( $actor_or_false->post_title ),
+ \sprintf(
+ '%s ',
+ \esc_url( \add_query_arg( 'resource', $search, $this->follow_url ) ),
+ \esc_html__( 'follow them', 'activitypub' )
+ )
+ );
+ } else {
+ \esc_html_e( 'No followers found.', 'activitypub' );
}
+ }
- if ( ! current_user_can( 'edit_user', $this->user_id ) ) {
- return;
+ /**
+ * Handles the row actions for each follower item.
+ *
+ * @param array $item The current follower item.
+ * @param string $column_name The current column name.
+ * @param string $primary The primary column name.
+ * @return string HTML for the row actions.
+ */
+ protected function handle_row_actions( $item, $column_name, $primary ) {
+ if ( $column_name !== $primary ) {
+ return '';
}
- $followers = $_REQUEST['followers']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+ $actions = array(
+ 'delete' => sprintf(
+ '%s ',
+ \wp_nonce_url(
+ \add_query_arg(
+ array(
+ 'action' => 'delete',
+ 'follower' => $item['id'],
+ )
+ ),
+ 'delete-follower_' . $item['id']
+ ),
+ /* translators: %s: username. */
+ \esc_attr( \sprintf( \__( 'Delete %s', 'activitypub' ), $item['username'] ) ),
+ \esc_html__( 'Delete', 'activitypub' )
+ ),
+ );
- if ( $this->current_action() === 'delete' ) {
- if ( ! is_array( $followers ) ) {
- $followers = array( $followers );
- }
- foreach ( $followers as $follower ) {
- FollowerCollection::remove_follower( $this->user_id, $follower );
- }
- }
+ return $this->row_actions( $actions );
}
/**
- * Returns user count.
+ * Checks if the searched actor can be followed.
+ *
+ * @param string $search The search string.
*
- * @return int
+ * @return \WP_Post|false The actor post or false.
*/
- public function get_user_count() {
- return FollowerCollection::count_followers( $this->user_id );
+ private function _is_followable( $search ) { // phpcs:ignore
+ if ( empty( $search ) ) {
+ return false;
+ }
+
+ $search = Sanitize::webfinger( $search );
+ if ( ! \filter_var( $search, FILTER_VALIDATE_EMAIL ) ) {
+ return false;
+ }
+
+ $search = Webfinger::resolve( $search );
+ if ( \is_wp_error( $search ) || ! \filter_var( $search, FILTER_VALIDATE_URL ) ) {
+ return false;
+ }
+
+ $actor = Actors::fetch_remote_by_uri( $search );
+ if ( \is_wp_error( $actor ) ) {
+ return false;
+ }
+
+ $does_follow = Following::check_status( $this->user_id, $actor->ID );
+ if ( $does_follow ) {
+ return false;
+ }
+
+ return $actor;
}
}
diff --git a/includes/table/class-following.php b/includes/table/class-following.php
new file mode 100644
index 000000000..07efad30e
--- /dev/null
+++ b/includes/table/class-following.php
@@ -0,0 +1,505 @@
+id === 'settings_page_activitypub' ) {
+ $this->user_id = Actors::BLOG_USER_ID;
+ } else {
+ $this->user_id = \get_current_user_id();
+
+ \add_action( 'admin_notices', array( $this, 'process_admin_notices' ) );
+ }
+
+ parent::__construct(
+ array(
+ 'singular' => \__( 'Following', 'activitypub' ),
+ 'plural' => \__( 'Followings', 'activitypub' ),
+ 'ajax' => false,
+ )
+ );
+
+ \add_action( 'load-' . get_current_screen()->id, array( $this, 'process_action' ), 20 );
+ }
+
+ /**
+ * Process action.
+ */
+ public function process_action() {
+ if ( ! \current_user_can( 'edit_user', $this->user_id ) ) {
+ return;
+ }
+
+ if ( ! $this->current_action() ) {
+ return;
+ }
+
+ $redirect_to = \add_query_arg(
+ array(
+ 'settings-updated' => true, // Tell WordPress to load settings errors transient.
+ 'action' => false, // Remove action parameter to prevent redirect loop.
+ )
+ );
+
+ switch ( $this->current_action() ) {
+ case 'delete':
+ $redirect_to = \remove_query_arg( array( 'follower', 'following' ), $redirect_to );
+
+ // Handle single follower deletion.
+ if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) {
+ $follower = \absint( $_GET['follower'] );
+ $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
+
+ if ( \wp_verify_nonce( $nonce, 'delete-follower_' . $follower ) ) {
+ Following_Collection::unfollow( $follower, $this->user_id );
+
+ \add_settings_error( 'activitypub', 'follower_deleted', \__( 'Account unfollowed.', 'activitypub' ), 'success' );
+ }
+ }
+
+ // Handle bulk actions.
+ if ( isset( $_REQUEST['following'], $_REQUEST['_wpnonce'] ) ) {
+ $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) );
+
+ if ( \wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
+ $following = array_map( 'absint', \wp_unslash( $_REQUEST['following'] ) );
+
+ foreach ( $following as $post_id ) {
+ Following_Collection::unfollow( $post_id, $this->user_id );
+ }
+
+ $count = \count( $following );
+ /* translators: %d: Number of accounts unfollowed. */
+ $message = \_n( '%d account unfollowed.', '%d accounts unfollowed.', $count, 'activitypub' );
+ $message = \sprintf( $message, \number_format_i18n( $count ) );
+
+ \add_settings_error( 'activitypub', 'followers_deleted', $message, 'success' );
+ }
+ }
+ break;
+ case 'follow':
+ $redirect_to = \remove_query_arg( array( 'resource', 's' ), $redirect_to );
+
+ if ( ! isset( $_REQUEST['activitypub-profile'], $_REQUEST['_wpnonce'] ) ) {
+ return;
+ }
+
+ $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) );
+ if ( ! \wp_verify_nonce( $nonce, 'activitypub-follow-nonce' ) ) {
+ return;
+ }
+
+ $profile = \sanitize_text_field( \wp_unslash( $_REQUEST['activitypub-profile'] ) );
+ if ( ! \is_email( \ltrim( $profile, '@' ) ) && empty( \wp_parse_url( $profile, PHP_URL_SCHEME ) ) ) {
+ // Add scheme if missing.
+ $profile = \esc_url_raw( 'https://' . \ltrim( $profile, '/' ) );
+ }
+
+ $result = follow( $profile, $this->user_id );
+ if ( \is_wp_error( $result ) ) {
+ /* translators: %s: Account profile that could not be followed */
+ \add_settings_error( 'activitypub', 'followed', \sprintf( \__( 'Unable to follow account “%s”. Please verify the account exists and try again.', 'activitypub' ), \esc_html( $profile ) ) );
+ $redirect_to = \add_query_arg( 'resource', $profile, $redirect_to );
+ } else {
+ \add_settings_error( 'activitypub', 'followed', \__( 'Account followed.', 'activitypub' ), 'success' );
+ }
+
+ break;
+ default:
+ break;
+ }
+
+ \set_transient( 'settings_errors', get_settings_errors(), 30 ); // 30 seconds.
+
+ \wp_safe_redirect( $redirect_to );
+ exit;
+ }
+
+ /**
+ * Process admin notices based on query parameters.
+ */
+ public function process_admin_notices() {
+ \settings_errors( 'activitypub' );
+ }
+
+ /**
+ * Get columns.
+ *
+ * @return array
+ */
+ public function get_columns() {
+ return array(
+ 'cb' => ' ',
+ 'username' => \__( 'Username', 'activitypub' ),
+ 'post_title' => \__( 'Name', 'activitypub' ),
+ 'webfinger' => \__( 'Profile', 'activitypub' ),
+ 'modified' => \__( 'Last updated', 'activitypub' ),
+ );
+ }
+
+ /**
+ * Returns sortable columns.
+ *
+ * @return array
+ */
+ public function get_sortable_columns() {
+ return array(
+ 'username' => array( 'username', true ),
+ 'post_title' => array( 'post_title', true ),
+ 'modified' => array( 'modified', false ),
+ );
+ }
+
+ /**
+ * Prepare items.
+ */
+ public function prepare_items() {
+ $status = Following_Collection::ALL;
+ $page_num = $this->get_pagenum();
+ $per_page = $this->get_items_per_page( 'activitypub_following_per_page' );
+ $args = array();
+
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET['orderby'] ) ) {
+ $args['orderby'] = \sanitize_text_field( \wp_unslash( $_GET['orderby'] ) );
+ }
+
+ if ( isset( $_GET['order'] ) ) {
+ $args['order'] = \sanitize_text_field( \wp_unslash( $_GET['order'] ) );
+ }
+
+ if ( isset( $_GET['s'] ) ) {
+ $args['s'] = self::normalize_search_term( \wp_unslash( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ }
+
+ if ( isset( $_GET['status'] ) ) {
+ $status = \sanitize_text_field( \wp_unslash( $_GET['status'] ) );
+ }
+
+ if ( Following_Collection::PENDING === $status ) {
+ $following_with_count = Following_Collection::get_pending_with_count( $this->user_id, $per_page, $page_num, $args );
+ } elseif ( Following_Collection::ACCEPTED === $status ) {
+ $following_with_count = Following_Collection::get_following_with_count( $this->user_id, $per_page, $page_num, $args );
+ } else {
+ $following_with_count = Following_Collection::get_all_with_count( $this->user_id, $per_page, $page_num, $args );
+ }
+
+ $followings = $following_with_count['following'];
+ $counter = $following_with_count['total'];
+
+ $this->items = array();
+ $this->set_pagination_args(
+ array(
+ 'total_items' => $counter,
+ 'total_pages' => ceil( $counter / $per_page ),
+ 'per_page' => $per_page,
+ )
+ );
+
+ foreach ( $followings as $following ) {
+ $actor = Actors::get_actor( $following );
+
+ if ( \is_wp_error( $actor ) ) {
+ continue;
+ }
+
+ $url = object_to_uri( $actor->get_url() ?? $actor->get_id() );
+ $webfinger = Webfinger::uri_to_acct( $url );
+
+ if ( \is_wp_error( $webfinger ) ) {
+ $webfinger = Webfinger::guess( $url );
+ }
+
+ $this->items[] = array(
+ 'id' => $following->ID,
+ 'icon' => $actor->get_icon()['url'] ?? '',
+ 'post_title' => $actor->get_name() ?? $actor->get_preferred_username(),
+ 'username' => $actor->get_preferred_username(),
+ 'url' => $url,
+ 'webfinger' => $webfinger,
+ 'status' => Following_Collection::check_status( $this->user_id, $following->ID ),
+ 'identifier' => $actor->get_id(),
+ 'modified' => $following->post_modified_gmt,
+ );
+ }
+ }
+
+ /**
+ * Returns views.
+ *
+ * @return string[]
+ */
+ public function get_views() {
+ $count = Following_Collection::count( $this->user_id );
+ $path = 'users.php?page=activitypub-following-list';
+ $status = Following_Collection::ALL;
+
+ if ( Actors::BLOG_USER_ID === $this->user_id ) {
+ $path = 'options-general.php?page=activitypub&tab=following';
+ }
+
+ if ( ! empty( $_GET['status'] ) ) {
+ $status = \sanitize_text_field( \wp_unslash( $_GET['status'] ) );
+ }
+
+ $links = array(
+ 'all' => array(
+ 'url' => admin_url( $path ),
+ 'label' => sprintf(
+ /* translators: %s: Number of users. */
+ \_nx(
+ 'All (%s) ',
+ 'All (%s) ',
+ $count[ Following_Collection::ALL ],
+ 'users',
+ 'activitypub'
+ ),
+ \number_format_i18n( $count[ Following_Collection::ALL ] )
+ ),
+ 'current' => Following_Collection::ALL === $status,
+ ),
+ 'accepted' => array(
+ 'url' => admin_url( $path . '&status=' . Following_Collection::ACCEPTED ),
+ 'label' => sprintf(
+ /* translators: %s: Number of users. */
+ \_nx(
+ 'Accepted (%s) ',
+ 'Accepted (%s) ',
+ $count[ Following_Collection::ACCEPTED ],
+ 'users',
+ 'activitypub'
+ ),
+ \number_format_i18n( $count[ Following_Collection::ACCEPTED ] )
+ ),
+ 'current' => Following_Collection::ACCEPTED === $status,
+ ),
+ 'pending' => array(
+ 'url' => admin_url( $path . '&status=' . Following_Collection::PENDING ),
+ 'label' => sprintf(
+ /* translators: %s: Number of users. */
+ \_nx(
+ 'Pending (%s) ',
+ 'Pending (%s) ',
+ $count[ Following_Collection::PENDING ],
+ 'users',
+ 'activitypub'
+ ),
+ \number_format_i18n( $count[ Following_Collection::PENDING ] )
+ ),
+ 'current' => Following_Collection::PENDING === $status,
+ ),
+ );
+
+ return $this->get_views_links( $links );
+ }
+
+ /**
+ * Returns bulk actions.
+ *
+ * @return array
+ */
+ public function get_bulk_actions() {
+ return array(
+ 'delete' => \__( 'Unfollow', 'activitypub' ),
+ );
+ }
+
+ /**
+ * Column default.
+ *
+ * @param array $item Item.
+ * @param string $column_name Column name.
+ * @return string
+ */
+ public function column_default( $item, $column_name ) {
+ if ( ! array_key_exists( $column_name, $item ) ) {
+ return \esc_html__( 'None', 'activitypub' );
+ }
+ return \esc_html( $item[ $column_name ] );
+ }
+
+ /**
+ * Column avatar.
+ *
+ * @param array $item Item.
+ * @return string
+ */
+ public function column_cb( $item ) {
+ return \sprintf( ' ', \esc_attr( $item['id'] ) );
+ }
+
+ /**
+ * Column url.
+ *
+ * @param array $item Item.
+ * @return string
+ */
+ public function column_username( $item ) {
+ $status = '';
+
+ if (
+ ( ! isset( $_GET['status'] ) || Following_Collection::ALL === $_GET['status'] ) &&
+ ( Following_Collection::PENDING === $item['status'] )
+ ) {
+ $status = \sprintf( ' — %s ', \esc_html__( 'Pending', 'activitypub' ) );
+ }
+
+ return sprintf(
+ ' %4$s %5$s ',
+ \esc_url( $item['icon'] ),
+ \esc_attr( $item['post_title'] ),
+ \esc_url( $item['url'] ),
+ \esc_html( $item['username'] ),
+ $status
+ );
+ }
+
+ /**
+ * Column WebFinger.
+ *
+ * @param array $item Item.
+ *
+ * @return string The WebFinger link.
+ */
+ public function column_webfinger( $item ) {
+ $webfinger = Sanitize::webfinger( $item['webfinger'] );
+
+ return \sprintf(
+ '@%2$s ',
+ \esc_url( $item['url'] ),
+ \esc_html( $webfinger )
+ );
+ }
+
+ /**
+ * Column modified.
+ *
+ * @param array $item Item.
+ * @return string
+ */
+ public function column_modified( $item ) {
+ $modified = \strtotime( $item['modified'] );
+ return \sprintf(
+ '%2$s ',
+ \esc_attr( \gmdate( 'c', $modified ) ),
+ \esc_html( \gmdate( \get_option( 'date_format' ), $modified ) )
+ );
+ }
+
+ /**
+ * Message to be displayed when there are no followings.
+ */
+ public function no_items() {
+ \esc_html_e( 'No profiles found.', 'activitypub' );
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $search = \sanitize_text_field( \wp_unslash( $_GET['s'] ?? '' ) );
+ if ( empty( $search ) ) {
+ return;
+ }
+
+ $search = Sanitize::webfinger( $search );
+ if ( filter_var( $search, FILTER_VALIDATE_EMAIL ) ) {
+ return;
+ }
+
+ $search = Webfinger::resolve( $search );
+
+ if ( ! is_wp_error( $search ) && filter_var( $search, FILTER_VALIDATE_URL ) ) {
+ $actor = Actors::fetch_remote_by_uri( $search );
+ if ( ! is_wp_error( $actor ) ) {
+ echo ' ';
+ \printf(
+ /* translators: %s: Actor name. */
+ \esc_html__( 'Would you like to follow %s?', 'activitypub' ),
+ \sprintf(
+ '%s ',
+ \esc_url( \add_query_arg( 'resource', $search ) ),
+ \esc_html( $actor->post_title )
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Single row.
+ *
+ * @param array $item Item.
+ */
+ public function single_row( $item ) {
+ \printf(
+ "",
+ \esc_attr( $item['id'] )
+ );
+ $this->single_row_columns( $item );
+ \printf( " \n" );
+ }
+
+ /**
+ * Handles the row actions for each following item.
+ *
+ * @param array $item The current following item.
+ * @param string $column_name The current column name.
+ * @param string $primary The primary column name.
+ * @return string HTML for the row actions.
+ */
+ protected function handle_row_actions( $item, $column_name, $primary ) {
+ if ( $column_name !== $primary ) {
+ return '';
+ }
+
+ $actions = array(
+ 'unfollow' => sprintf(
+ '%s ',
+ \wp_nonce_url(
+ \add_query_arg(
+ array(
+ 'action' => 'delete',
+ 'follower' => $item['id'],
+ )
+ ),
+ 'delete-follower_' . $item['id']
+ ),
+ /* translators: %s: username. */
+ \esc_attr( \sprintf( \__( 'Unfollow %s', 'activitypub' ), $item['username'] ) ),
+ \esc_html__( 'Unfollow', 'activitypub' )
+ ),
+ );
+
+ return $this->row_actions( $actions );
+ }
+}
diff --git a/includes/table/trait-actor-list-table.php b/includes/table/trait-actor-list-table.php
new file mode 100644
index 000000000..53003d9d0
--- /dev/null
+++ b/includes/table/trait-actor-list-table.php
@@ -0,0 +1,28 @@
+get_attributed_to() );
- if ( ! $actor || is_wp_error( $actor ) ) {
- $followers = null;
- } else {
+ $public = 'https://www.w3.org/ns/activitystreams#Public';
+ $followers = null;
+ $replied_to = null;
+
+ $actor = Actors::get_by_resource( $this->get_attributed_to() );
+ if ( ! \is_wp_error( $actor ) ) {
$followers = $actor->get_followers();
}
+
$mentions = array_values( $this->get_mentions() );
+ if ( $this->get_in_reply_to() ) {
+ $object = Http::get_remote_object( $this->get_in_reply_to() );
+ if ( $object && ! \is_wp_error( $object ) && isset( $object['attributedTo'] ) ) {
+ $replied_to = array( object_to_uri( $object['attributedTo'] ) );
+ }
+ }
+
switch ( $this->get_content_visibility() ) {
case ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC:
$activity_object->add_to( $public );
$activity_object->add_cc( $followers );
$activity_object->add_cc( $mentions );
+ $activity_object->add_cc( $replied_to );
break;
case ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC:
$activity_object->add_to( $followers );
$activity_object->add_to( $mentions );
+ $activity_object->add_to( $replied_to );
$activity_object->add_cc( $public );
break;
case ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE:
$activity_object->add_to( $mentions );
+ $activity_object->add_to( $replied_to );
}
return $activity_object;
@@ -231,28 +246,6 @@ public function to_activity( $type ) {
protected function get_locale() {
$lang = \strtolower( \strtok( \get_locale(), '_-' ) );
- if ( $this->item instanceof \WP_Post ) {
- /**
- * Deprecates the `activitypub_post_locale` filter.
- *
- * @param string $lang The locale of the post.
- * @param mixed $item The post object.
- *
- * @return string The filtered locale of the post.
- */
- $lang = apply_filters_deprecated(
- 'activitypub_post_locale',
- array(
- $lang,
- $this->item->ID,
- $this->item,
- ),
- '5.4.0',
- 'activitypub_locale',
- 'Use the `activitypub_locale` filter instead.'
- );
- }
-
/**
* Filter the locale of the post.
*
@@ -379,4 +372,13 @@ protected function get_mentions() {
$this->item
);
}
+
+ /**
+ * Returns the in reply to.
+ *
+ * @return string|array|null The in reply to.
+ */
+ protected function get_in_reply_to() {
+ return null;
+ }
}
diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php
index 0fcef68cb..8efd0fb3e 100644
--- a/includes/transformer/class-post.php
+++ b/includes/transformer/class-post.php
@@ -57,6 +57,7 @@ public function to_object() {
$object->set_sensitive( true );
$object->set_summary( $content_warning );
$object->set_summary_map( null );
+ $object->set_dcterms( array( 'subject' => $content_warning ) );
}
return $object;
@@ -283,8 +284,11 @@ protected function get_attachment() {
return array();
}
- // phpcs:ignore Universal.Operators.DisallowShortTernary
- $max_media = \get_post_meta( $this->item->ID, 'activitypub_max_image_attachments', true ) ?: \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS );
+ $max_media = \get_post_meta( $this->item->ID, 'activitypub_max_image_attachments', true );
+
+ if ( ! is_numeric( $max_media ) ) {
+ $max_media = \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS );
+ }
/**
* Filters the maximum number of media attachments allowed in a post.
@@ -297,6 +301,10 @@ protected function get_attachment() {
*/
$max_media = (int) \apply_filters( 'activitypub_max_image_attachments', $max_media );
+ if ( 0 === $max_media ) {
+ return array();
+ }
+
$media = array(
'image' => array(),
'audio' => array(),
@@ -581,7 +589,7 @@ public function generate_reply_link( $block_content, $block ) {
// Get webfinger identifier.
$webfinger = '';
if ( ! empty( $author['webfinger'] ) ) {
- $webfinger = $author['webfinger'];
+ $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
} elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
// Construct webfinger-style identifier from username and domain.
$domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
@@ -606,7 +614,7 @@ public function generate_reply_link( $block_content, $block ) {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
*
- * @return string|null The in-reply-to URL of the post.
+ * @return string|array|null The in-reply-to URL of the post.
*/
protected function get_in_reply_to() {
if ( ! site_supports_blocks() ) {
diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php
index 33eeb5b55..9718e3fa6 100644
--- a/includes/wp-admin/class-admin.php
+++ b/includes/wp-admin/class-admin.php
@@ -88,36 +88,53 @@ public static function admin_notices() {
}
/**
- * Display one admin menu notice about configuration problems or conflicts.
- *
- * @param string $admin_notice The notice to display.
- * @param string $level The level of the notice (error, warning, success, info).
+ * Load user settings page
*/
- private static function show_admin_notice( $admin_notice, $level ) {
- ?>
-
-
-
- 'activitypub_rfc9421_signature' )
+ );
+
if ( ! defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) ) {
\add_settings_field(
'activitypub_shared_inbox',
@@ -178,6 +187,24 @@ public static function render_authorized_fetch_field() {
+
+
+ />
+
+
+
+
+
+
+
-
+ @username@example.com are accepted and will be automatically normalized to the correct format.', 'activitypub' ) ); ?>
get_error_data();
+ $author_url = $resource;
+ if ( isset( $data['data'] ) && \is_string( $data['data'] ) ) {
+ $author_url = $data['data'];
+ }
+
$health_messages = array(
'webfinger_url_not_accessible' => \sprintf(
$not_accessible,
- $url->get_error_data()['data']
+ $author_url
),
'webfinger_url_invalid_response' => \sprintf(
// translators: %s: Author URL.
$invalid_response,
- $url->get_error_data()['data']
+ $author_url
),
);
$message = null;
@@ -320,9 +326,9 @@ public static function debug_information( $info ) {
'private' => false,
);
- $info['activitypub']['fields']['authorized_fetch'] = array(
- 'label' => \__( 'Authorized Fetch', 'activitypub' ),
- 'value' => \esc_attr( (int) \get_option( 'activitypub_authorized_fetch', '0' ) ),
+ $info['activitypub']['fields']['activitypub_outbox_purge_days'] = array(
+ 'label' => \__( 'Outbox Retention Period', 'activitypub' ),
+ 'value' => \esc_attr( (int) \get_option( 'activitypub_outbox_purge_days', 180 ) ),
'private' => false,
);
@@ -332,6 +338,18 @@ public static function debug_information( $info ) {
'private' => false,
);
+ $info['activitypub']['fields']['content_negotiation'] = array(
+ 'label' => \__( 'Content Negotiation', 'activitypub' ),
+ 'value' => \esc_attr( (int) \get_option( 'activitypub_content_negotiation', '1' ) ),
+ 'private' => false,
+ );
+
+ $info['activitypub']['fields']['authorized_fetch'] = array(
+ 'label' => \__( 'Authorized Fetch', 'activitypub' ),
+ 'value' => \esc_attr( (int) \get_option( 'activitypub_authorized_fetch', '0' ) ),
+ 'private' => false,
+ );
+
$info['activitypub']['fields']['shared_inbox'] = array(
'label' => \__( 'Shared Inbox', 'activitypub' ),
'value' => \esc_attr( (int) \get_option( 'activitypub_shared_inbox', '0' ) ),
diff --git a/includes/wp-admin/class-menu.php b/includes/wp-admin/class-menu.php
index e6d84f657..9530940b8 100644
--- a/includes/wp-admin/class-menu.php
+++ b/includes/wp-admin/class-menu.php
@@ -19,7 +19,7 @@ class Menu {
*/
public static function admin_menu() {
$settings_page = \add_options_page(
- 'Welcome',
+ \_x( 'Welcome', 'page title', 'activitypub' ),
'ActivityPub',
'manage_options',
'activitypub',
@@ -28,6 +28,8 @@ public static function admin_menu() {
\add_action( 'load-' . $settings_page, array( Settings::class, 'add_settings_help_tab' ) );
\add_action( 'load-users.php', array( Settings::class, 'add_users_help_tab' ) );
+ \add_action( 'load-' . $settings_page, array( Admin::class, 'add_settings_list_tables' ) );
+ \add_action( 'load-' . $settings_page, array( Screen_Options::class, 'add_settings_list_options' ) );
// User has to be able to publish posts.
if ( user_can_activitypub( \get_current_user_id() ) ) {
@@ -39,7 +41,26 @@ public static function admin_menu() {
array( Admin::class, 'followers_list_page' )
);
- \add_action( 'load-' . $followers_list_page, array( Admin::class, 'add_followers_list_help_tab' ) );
+ \add_action( 'load-' . $followers_list_page, array( Admin::class, 'add_followers_list_table' ) );
+ \add_action( 'load-' . $followers_list_page, array( Screen_Options::class, 'add_followers_list_options' ) );
+
+ /**
+ * Filter to show the following UI.
+ *
+ * @param bool $show Show following UI.
+ */
+ if ( \apply_filters( 'activitypub_show_following_ui', false ) ) {
+ $following_list_page = \add_users_page(
+ \__( 'Following ⁂', 'activitypub' ),
+ \__( 'Following ⁂', 'activitypub' ),
+ 'activitypub',
+ 'activitypub-following-list',
+ array( Admin::class, 'following_list_page' )
+ );
+
+ \add_action( 'load-' . $following_list_page, array( Admin::class, 'add_following_list_table' ) );
+ \add_action( 'load-' . $following_list_page, array( Screen_Options::class, 'add_following_list_options' ) );
+ }
\add_users_page(
\__( 'Extra Fields ⁂', 'activitypub' ),
diff --git a/includes/wp-admin/class-screen-options.php b/includes/wp-admin/class-screen-options.php
new file mode 100644
index 000000000..a9fa0c81a
--- /dev/null
+++ b/includes/wp-admin/class-screen-options.php
@@ -0,0 +1,170 @@
+ \__( 'Followers per page', 'activitypub' ),
+ 'default' => 20,
+ 'option' => 'activitypub_followers_per_page',
+ )
+ );
+ }
+
+ /**
+ * Add screen options for following list.
+ *
+ * @see Menu::admin_menu()
+ */
+ public static function add_following_list_options() {
+ \add_screen_option(
+ 'per_page',
+ array(
+ 'label' => \__( 'Following per page', 'activitypub' ),
+ 'default' => 20,
+ 'option' => 'activitypub_following_per_page',
+ )
+ );
+ }
+
+ /**
+ * Set per_page screen options.
+ *
+ * @param mixed $status Screen option value. Default false to skip.
+ * @param string $option The option name.
+ * @param mixed $value The option value.
+ * @return int
+ */
+ public static function set_per_page_option( $status, $option, $value ) {
+ if ( 'activitypub_followers_per_page' === $option || 'activitypub_following_per_page' === $option ) {
+ $value = (int) $value;
+
+ if ( $value > 0 && $value <= 100 ) {
+ return $value;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Add screen options.
+ *
+ * @param string $screen_settings The screen settings.
+ * @param object $screen The screen object.
+ *
+ * @return string The screen settings.
+ */
+ public static function add_screen_option( $screen_settings, $screen ) {
+ if ( 'settings_page_activitypub' !== $screen->id ) {
+ return $screen_settings;
+ }
+
+ // Verify screen options nonce.
+ if ( isset( $_POST['screenoptionnonce'] ) ) {
+ $nonce = \sanitize_text_field( \wp_unslash( $_POST['screenoptionnonce'] ) );
+ if ( ! \wp_verify_nonce( $nonce, 'screen-options-nonce' ) ) {
+ return $screen_settings;
+ }
+ }
+
+ $screen_options = array(
+ 'activitypub_show_welcome_tab' => __( 'Welcome Page', 'activitypub' ),
+ 'activitypub_show_advanced_tab' => __( 'Advanced Settings', 'activitypub' ),
+ );
+
+ /**
+ * Filters Activitypub settings screen options.
+ *
+ * @param string[] $screen_options Screen options. An array of user meta keys and screen option labels.
+ */
+ $screen_options = \apply_filters( 'activitypub_screen_options', $screen_options );
+ if ( empty( $screen_options ) ) {
+ return $screen_settings;
+ }
+
+ foreach ( $screen_options as $option => $label ) {
+ if ( isset( $_POST[ $option ] ) ) {
+ $value = \sanitize_text_field( \wp_unslash( $_POST[ $option ] ) );
+ \update_user_meta( \get_current_user_id(), $option, empty( $value ) ? 0 : 1 );
+ }
+ }
+
+ ob_start();
+ ?>
+
+
+
+ $label ) : ?>
+
+
+ />
+
+
+
+
+
+ id ) {
+ return $show_submit;
+ }
+
+ return true;
+ }
+}
diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php
index 275f718b6..ffc6c8233 100644
--- a/includes/wp-admin/class-settings-fields.php
+++ b/includes/wp-admin/class-settings-fields.php
@@ -83,16 +83,14 @@ public static function register_settings_fields() {
);
}
- if ( ! site_supports_blocks() || \is_plugin_active( 'classic-editor/classic-editor.php' ) ) {
- add_settings_field(
- 'activitypub_max_image_attachments',
- __( 'Media attachments', 'activitypub' ),
- array( self::class, 'render_max_image_attachments_field' ),
- 'activitypub_settings',
- 'activitypub_activities',
- array( 'label_for' => 'activitypub_max_image_attachments' )
- );
- }
+ add_settings_field(
+ 'activitypub_max_image_attachments',
+ __( 'Media attachments', 'activitypub' ),
+ array( self::class, 'render_max_image_attachments_field' ),
+ 'activitypub_settings',
+ 'activitypub_activities',
+ array( 'label_for' => 'activitypub_max_image_attachments' )
+ );
add_settings_field(
'activitypub_support_post_types',
@@ -337,6 +335,7 @@ public static function render_allow_interactions_field() {
$allow_likes = get_option( 'activitypub_allow_likes', '1' );
$allow_reposts = get_option( 'activitypub_allow_reposts', '1' );
+ $auto_approve = get_option( 'activitypub_auto_approve_reactions', '0' );
?>
@@ -351,7 +350,13 @@ public static function render_allow_interactions_field() {
-
+
+
+
+ />
+
+
+
'integer',
+ 'description' => \__( 'Auto approve Reactions.', 'activitypub' ),
+ 'default' => '0',
+ 'sanitize_callback' => 'absint',
+ )
+ );
+
\register_setting(
'activitypub',
'activitypub_relays',
@@ -211,6 +220,16 @@ public static function register_settings() {
)
);
+ \register_setting(
+ 'activitypub_advanced',
+ 'activitypub_rfc9421_signature',
+ array(
+ 'type' => 'boolean',
+ 'description' => 'Use RFC-9421 signature.',
+ 'default' => false,
+ )
+ );
+
\register_setting(
'activitypub_advanced',
'activitypub_shared_inbox',
@@ -292,7 +311,7 @@ public static function register_settings() {
'type' => 'array',
'description' => 'An array of URLs that the blog user is known by.',
'default' => array(),
- 'sanitize_callback' => array( Sanitize::class, 'url_list' ),
+ 'sanitize_callback' => array( Sanitize::class, 'identifier_list' ),
)
);
}
@@ -303,16 +322,11 @@ public static function register_settings() {
public static function settings_page() {
$show_welcome_tab = \get_user_meta( \get_current_user_id(), 'activitypub_show_welcome_tab', true );
$show_advanced_tab = \get_user_meta( \get_current_user_id(), 'activitypub_show_advanced_tab', true );
- $default_tab = $show_welcome_tab ? 'welcome' : 'settings';
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $tab = isset( $_GET['tab'] ) ? \sanitize_key( $_GET['tab'] ) : $default_tab;
-
- // Redirect welcome tab to settings if skipped.
- if ( 'welcome' === $tab && ! $show_welcome_tab ) {
- $tab = 'settings';
- }
-
- $settings_tabs = array();
+ $settings_tabs = array();
+ $settings_tab = array(
+ 'label' => __( 'Settings', 'activitypub' ),
+ 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/settings.php',
+ );
if ( $show_welcome_tab ) {
$settings_tabs['welcome'] = array(
@@ -321,10 +335,7 @@ public static function settings_page() {
);
}
- $settings_tabs['settings'] = array(
- 'label' => __( 'Settings', 'activitypub' ),
- 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/settings.php',
- );
+ $settings_tabs['settings'] = $settings_tab;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ( isset( $_GET['tab'] ) && 'advanced' === $_GET['tab'] ) || $show_advanced_tab ) {
@@ -341,8 +352,15 @@ public static function settings_page() {
);
$settings_tabs['followers'] = array(
'label' => __( 'Followers', 'activitypub' ),
- 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/blog-followers-list.php',
+ 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/followers-list.php',
);
+
+ if ( \apply_filters( 'activitypub_show_following_ui', false ) ) {
+ $settings_tabs['following'] = array(
+ 'label' => __( 'Following', 'activitypub' ),
+ 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/following-list.php',
+ );
+ }
}
/**
@@ -350,8 +368,21 @@ public static function settings_page() {
*
* @param array $settings_tabs The tabs to display.
*/
- $custom_tabs = \apply_filters( 'activitypub_admin_settings_tabs', array() );
- $settings_tabs = \array_merge( $settings_tabs, $custom_tabs );
+ $settings_tabs = \apply_filters( 'activitypub_admin_settings_tabs', $settings_tabs );
+
+ if ( empty( $settings_tabs ) ) {
+ _doing_it_wrong( __FUNCTION__, 'No settings tabs found. There should be at least one tab to show a settings page.', 'unreleased' );
+ $settings_tabs['settings'] = $settings_tab;
+ }
+
+ $tab_keys = array_keys( $settings_tabs );
+ $default_tab = reset( $tab_keys );
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $tab = isset( $_GET['tab'] ) ? \sanitize_key( $_GET['tab'] ) : $default_tab;
+
+ if ( ! isset( $settings_tabs[ $tab ] ) ) {
+ $tab = $default_tab;
+ }
switch ( $tab ) {
case 'blog-profile':
@@ -370,14 +401,9 @@ public static function settings_page() {
break;
}
- if ( ! isset( $settings_tabs[ $tab ] ) ) {
- $tab = $default_tab;
- }
-
// Only show tabs if there are more than one.
- if ( \count( $settings_tabs ) <= 1 ) {
- $labels = array();
- } else {
+ $labels = array();
+ if ( \count( $settings_tabs ) > 1 ) {
$labels = \wp_list_pluck( $settings_tabs, 'label' );
}
@@ -518,77 +544,6 @@ public static function handle_welcome_query_arg() {
}
}
- /**
- * Add screen option.
- *
- * @param string $screen_settings The screen settings.
- * @param object $screen The screen object.
- *
- * @return string The screen settings.
- */
- public static function add_screen_option( $screen_settings, $screen ) {
- if ( 'settings_page_activitypub' !== $screen->id ) {
- return $screen_settings;
- }
-
- // Verify screen options nonce.
- if ( isset( $_POST['screenoptionnonce'] ) ) {
- $nonce = \sanitize_text_field( \wp_unslash( $_POST['screenoptionnonce'] ) );
- if ( ! \wp_verify_nonce( $nonce, 'screen-options-nonce' ) ) {
- return $screen_settings;
- }
- }
-
- if ( isset( $_POST['activitypub_show_welcome_tab'] ) ) {
- $welcome = \sanitize_text_field( \wp_unslash( $_POST['activitypub_show_welcome_tab'] ) );
- $welcome_checked = empty( $welcome ) ? 0 : 1;
- \update_user_meta( \get_current_user_id(), 'activitypub_show_welcome_tab', $welcome_checked );
- }
-
- if ( isset( $_POST['activitypub_show_advanced_tab'] ) ) {
- $advanced_settings = \sanitize_text_field( \wp_unslash( $_POST['activitypub_show_advanced_tab'] ) );
- $advanced_settings_checked = empty( $advanced_settings ) ? 0 : 1;
- \update_user_meta( \get_current_user_id(), 'activitypub_show_advanced_tab', $advanced_settings_checked );
- }
-
- $screen_settings = '
- ' . \esc_html__( 'Settings Pages', 'activitypub' ) . '
-
- ' . \esc_html__( 'Some settings pages can be shown or hidden by using the checkboxes.', 'activitypub' ) . '
-
-
-
-
-
- ' . \esc_html__( 'Welcome Page', 'activitypub' ) . '
-
-
-
-
- ' . \esc_html__( 'Advanced Settings', 'activitypub' ) . '
-
-
- ';
-
- return $screen_settings;
- }
-
- /**
- * Show the submit button on the screen options page.
- *
- * @param bool $show_submit Whether to show the submit button.
- * @param object $screen The screen object.
- *
- * @return bool Whether to show the submit button.
- */
- public static function screen_options_show_submit( $show_submit, $screen ) {
- if ( 'settings_page_activitypub' !== $screen->id ) {
- return $show_submit;
- }
-
- return true;
- }
-
/**
* Returns an array of recommended plugins for ActivityPub.
*/
diff --git a/includes/wp-admin/class-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php
index 1a0e6b1f1..11381a396 100644
--- a/includes/wp-admin/class-user-settings-fields.php
+++ b/includes/wp-admin/class-user-settings-fields.php
@@ -87,7 +87,7 @@ public static function register_settings() {
array( self::class, 'also_known_as_callback' ),
'activitypub_user_settings',
'activitypub_user_profile',
- array( 'label_for' => 'activitypub_blog_user_also_known_as' )
+ array( 'label_for' => 'activitypub_also_known_as' )
);
}
@@ -262,15 +262,15 @@ public static function also_known_as_callback() {
?>
-
+ @username@example.com are accepted and will be automatically normalized to the correct format.', 'activitypub' ) ); ?>
callbacks );
+ $pattern = '/^' . \preg_quote( self::class, '/' ) . '/';
+ foreach ( $wp_filter['activitypub_onboarding_steps']->callbacks as $callbacks ) {
+ $matching_keys = \preg_grep( $pattern, \array_keys( $callbacks ) );
+ $count += \count( $matching_keys );
+ }
}
return $count;
@@ -165,29 +170,39 @@ private static function get_total_steps_count() {
* Get the next incomplete step.
*/
private static function get_next_incomplete_step() {
- if ( '0' !== \get_option( 'activitypub_checklist_health_check_issues', (string) Health_Check::count_results( 'critical' ) ) ) {
+ if ( self::has_step( 'site_health' ) && '0' !== \get_option( 'activitypub_checklist_health_check_issues', (string) Health_Check::count_results( 'critical' ) ) ) {
return 'site_health';
}
- if ( false === \get_option( 'activitypub_checklist_fediverse_intro_visited', false ) ) {
+ if ( self::has_step( 'fediverse_intro' ) && ! \get_option( 'activitypub_checklist_fediverse_intro_visited', false ) ) {
return 'fediverse_intro';
}
- if ( false === \get_option( 'activitypub_checklist_settings_visited', false ) ) {
+ if ( self::has_step( 'profile_mode' ) && ! \get_option( 'activitypub_checklist_settings_visited', false ) ) {
return 'profile_mode';
}
- if ( false === \get_option( 'activitypub_checklist_profile_setup_visited', false ) ) {
+ if ( self::has_step( 'profile_setup' ) && ! \get_option( 'activitypub_checklist_profile_setup_visited', false ) ) {
return 'profile_setup';
}
- if ( false === \get_option( 'activitypub_checklist_blocks_visited', false ) ) {
+ if ( self::has_step( 'features' ) && ! \get_option( 'activitypub_checklist_blocks_visited', false ) ) {
return 'features';
}
return '';
}
+ /**
+ * Check if a step exists.
+ *
+ * @param string $step Step slug.
+ * @return bool
+ */
+ private static function has_step( $step ) {
+ return \has_action( 'activitypub_onboarding_steps', array( self::class, 'render_step_' . $step ) );
+ }
+
/**
* Render onboarding steps section.
*/
@@ -335,7 +350,7 @@ public static function render_step_profile_setup() {
$checked = '1' === \get_option( 'activitypub_checklist_profile_setup_visited', false );
$step_class = $checked ? 'activitypub-step-completed' : '';
$next_step = self::get_next_incomplete_step();
- $button_class = ( 'profile_mode' === $next_step ) ? 'button-primary' : 'button-secondary';
+ $button_class = ( 'profile_setup' === $next_step ) ? 'button-primary' : 'button-secondary';
?>
@@ -355,6 +370,10 @@ public static function render_step_profile_setup() {
+
+
+
+
diff --git a/includes/wp-admin/import/class-starter-kit.php b/includes/wp-admin/import/class-starter-kit.php
new file mode 100644
index 000000000..ef23e7742
--- /dev/null
+++ b/includes/wp-admin/import/class-starter-kit.php
@@ -0,0 +1,298 @@
+' . \esc_html( $error_message ) . ' ';
+ \printf(
+ /* translators: 1: php.ini, 2: post_max_size, 3: upload_max_filesize */
+ \esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your %1$s file or by %2$s being defined as smaller than %3$s in %1$s.', 'activitypub' ),
+ 'php.ini',
+ 'post_max_size',
+ 'upload_max_filesize'
+ );
+ echo '';
+ return false;
+ }
+
+ $file_info = \wp_check_filetype( \sanitize_file_name( $_FILES['import']['name'] ), array( 'json' => 'application/json' ) );
+ if ( 'application/json' !== $file_info['type'] ) {
+ \printf( '%s %s
', \esc_html( $error_message ), \esc_html__( 'The uploaded file must be a JSON file. Please try again with the correct file format.', 'activitypub' ) );
+ return false;
+ }
+
+ $overrides = array(
+ 'test_form' => false,
+ 'test_type' => false,
+ );
+
+ $upload = \wp_handle_upload( $_FILES['import'], $overrides );
+
+ if ( isset( $upload['error'] ) ) {
+ \printf( '%s %s
', \esc_html( $error_message ), \esc_html( $upload['error'] ) );
+ return false;
+ }
+
+ // Construct the attachment array.
+ $attachment = array(
+ 'post_title' => \wp_basename( $upload['file'] ),
+ 'post_content' => $upload['url'],
+ 'post_mime_type' => $upload['type'],
+ 'guid' => $upload['url'],
+ 'context' => 'import',
+ 'post_status' => 'private',
+ );
+
+ // Save the data.
+ self::$import_id = \wp_insert_attachment( $attachment, $upload['file'] );
+
+ // Schedule a cleanup for one day from now in case of failed import or missing wp_import_cleanup() call.
+ \wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( self::$import_id ) );
+
+ return true;
+ }
+
+ /**
+ * Import options.
+ */
+ public static function import_options() {
+ $activitypub_users = function ( $users ) {
+ // Add blog user to the html output if enabled.
+ $users = \preg_replace( '/<\/select>/', '' . \__( 'Blog User', 'activitypub' ) . ' ', $users );
+ return $users;
+ };
+
+ if ( ! is_user_type_disabled( 'blog' ) ) {
+ \add_filter(
+ 'wp_dropdown_users',
+ $activitypub_users
+ );
+ }
+ ?>
+
+ get_contents( $file );
+ if ( false === $file_contents ) {
+ \printf( '%s %s
', \esc_html( $error_message ), \esc_html__( 'Could not read the uploaded file.', 'activitypub' ) );
+ return;
+ }
+
+ self::$starter_kit = \json_decode( $file_contents, true );
+ if ( null === self::$starter_kit ) {
+ \printf( '%s %s
', \esc_html( $error_message ), \esc_html__( 'Invalid JSON format in the uploaded file.', 'activitypub' ) );
+ return;
+ }
+
+ \wp_suspend_cache_invalidation();
+ \wp_defer_term_counting( true );
+ \wp_defer_comment_counting( true );
+
+ /**
+ * Fires when the Starter Kit import starts.
+ */
+ \do_action( 'import_start' );
+
+ $result = self::follow();
+
+ \wp_suspend_cache_invalidation( false );
+ \wp_defer_term_counting( false );
+ \wp_defer_comment_counting( false );
+
+ \wp_import_cleanup( self::$import_id );
+
+ if ( \is_wp_error( $result ) ) {
+ \printf( '%s %s
', \esc_html( $error_message ), \esc_html( $result->get_error_message() ) );
+ } else {
+ \printf( '%s
', \esc_html__( 'All done.', 'activitypub' ) );
+ }
+
+ /**
+ * Fires when the Starter Kit import ends.
+ */
+ \do_action( 'import_end' );
+ }
+
+ /**
+ * Process posts.
+ *
+ * @return true|\WP_Error True on success, WP_Error on failure.
+ */
+ public static function follow() {
+ $skipped = 0;
+ $followed = 0;
+
+ $items = self::$starter_kit['items'] ?? array();
+
+ foreach ( $items as $item ) {
+ if ( ! is_actor( $item ) ) {
+ ++$skipped;
+ continue;
+ }
+
+ $result = follow( object_to_uri( $item ), self::$author );
+
+ if ( \is_wp_error( $result ) ) {
+ ++$skipped;
+ } else {
+ /* translators: %s: Account ID */
+ \printf( '' . \esc_html__( 'Followed %s', 'activitypub' ) . '
', \esc_html( $item['id'] ) );
+ ++$followed;
+ }
+ }
+
+ echo ' ';
+
+ /* translators: %d: Number of followed actors */
+ \printf( '%s
', \esc_html( \sprintf( \_n( 'Followed %s Actor.', 'Followed %s Actors.', $followed, 'activitypub' ), \number_format_i18n( $followed ) ) ) );
+ /* translators: %d: Number of skipped items */
+ \printf( '%s
', \esc_html( \sprintf( \_n( 'Skipped %s Item.', 'Skipped %s Items.', $skipped, 'activitypub' ), \number_format_i18n( $skipped ) ) ) );
+
+ return true;
+ }
+
+ /**
+ * Intro.
+ */
+ public static function greet() {
+ echo '';
+ echo '
' . \esc_html__( 'Starter Kits use the ActivityPub protocol with custom extensions to automate tasks such as following accounts, blocking unwanted content, and applying default configurations. The importer will automatically follow every user listed in the kit, helping users connect right away. Support for additional actions and features will be added over time.', 'activitypub' ) . '
';
+
+ \wp_import_upload_form( 'admin.php?import=starter-kit&step=1' );
+
+ echo '
';
+ }
+
+ /**
+ * Header.
+ */
+ public static function header() {
+ echo '';
+ echo '
' . \esc_html__( 'Import a Fediverse Starter Kit (Beta)', 'activitypub' ) . ' ';
+ }
+
+ /**
+ * Footer.
+ */
+ public static function footer() {
+ echo '';
+ }
+}
diff --git a/includes/wp-admin/import/load.php b/includes/wp-admin/import/load.php
index 27d5faf1d..494cab921 100644
--- a/includes/wp-admin/import/load.php
+++ b/includes/wp-admin/import/load.php
@@ -26,4 +26,13 @@ function load() {
\__( 'Import content from Mastodon.', 'activitypub' ),
array( __NAMESPACE__ . '\Mastodon', 'dispatch' )
);
+
+ if ( \apply_filters( 'activitypub_show_following_ui', false ) ) {
+ \register_importer(
+ 'starter-kit',
+ \__( 'Fediverse Starter Kits (Beta)', 'activitypub' ),
+ \__( 'Automatically follow a collection of Fediverse users.', 'activitypub' ),
+ array( __NAMESPACE__ . '\Starter_Kit', 'dispatch' )
+ );
+ }
}
diff --git a/integration/class-jetpack.php b/integration/class-jetpack.php
index 5586cf79e..1e30bbd2c 100644
--- a/integration/class-jetpack.php
+++ b/integration/class-jetpack.php
@@ -8,6 +8,7 @@
namespace Activitypub\Integration;
use Activitypub\Comment;
+use Activitypub\Collection\Followers;
/**
* Jetpack integration class.
@@ -35,9 +36,8 @@ public static function add_sync_meta( $allow_list ) {
return $allow_list;
}
$activitypub_meta_keys = array(
- '_activitypub_user_id',
+ Followers::FOLLOWER_META_KEY,
'_activitypub_inbox',
- '_activitypub_actor_json',
);
return \array_merge( $allow_list, $activitypub_meta_keys );
}
diff --git a/integration/class-surge.php b/integration/class-surge.php
index f129646d4..fb979de0c 100644
--- a/integration/class-surge.php
+++ b/integration/class-surge.php
@@ -37,6 +37,10 @@ public static function init() {
* Add the Surge cache config.
*/
public static function add_cache_config() {
+ if ( \defined( 'WP_CACHE_CONFIG' ) ) {
+ return;
+ }
+
$file = self::get_config_file_path();
if ( ! \wp_is_writable( $file ) ) {
@@ -72,6 +76,10 @@ public static function add_cache_config() {
* Remove the Surge cache config.
*/
public static function remove_cache_config() {
+ if ( ! \defined( 'WP_CACHE_CONFIG' ) ) {
+ return;
+ }
+
$file = self::get_config_file_path();
if ( ! \wp_is_writable( $file ) ) {
diff --git a/integration/class-wp-rest-cache.php b/integration/class-wp-rest-cache.php
new file mode 100644
index 000000000..4d40de8d2
--- /dev/null
+++ b/integration/class-wp-rest-cache.php
@@ -0,0 +1,148 @@
+post_type, $post_types, true ) ) {
+ return;
+ }
+
+ Caching::get_instance()->delete_object_type_caches( 'ActivityPub' );
+ }
+
+ /**
+ * Reset cache by transition comment status.
+ *
+ * @param string $new_status The new comment status.
+ * @param string $old_status The old comment status.
+ * @param \WP_Comment $comment Comment object.
+ */
+ public static function transition_comment_status( $new_status, $old_status, $comment ) {
+ if ( 'approved' !== $new_status && 'approved' !== $old_status ) {
+ return;
+ }
+
+ $comment_types = Comment::get_comment_type_slugs();
+ $comment_types[] = 'comment';
+
+ if ( ! \in_array( $comment->comment_type ?: 'comment', $comment_types, true ) ) { // phpcs:ignore Universal.Operators.DisallowShortTernary
+ return;
+ }
+
+ Caching::get_instance()->delete_object_type_caches( 'ActivityPub' );
+ }
+
+ /**
+ * Test, whether the current endpoint is an ActivityPub endpoint.
+ *
+ * @param string $uri URI to test.
+ *
+ * @return bool Whether the current endpoint is an ActivityPub endpoint.
+ */
+ private static function is_activitypub_endpoint( $uri ) {
+ $search = '/' . ACTIVITYPUB_REST_NAMESPACE . '/';
+
+ return \str_contains( $uri, $search ) || \str_contains( $uri, 'rest_route=' . \rawurlencode( $search ) );
+ }
+}
diff --git a/integration/load.php b/integration/load.php
index 16a7b5ad7..78b0dd6b9 100644
--- a/integration/load.php
+++ b/integration/load.php
@@ -123,6 +123,10 @@ function ( $transformer, $data, $object_class ) {
WPML::init();
}
+ if ( \class_exists( 'WP_Rest_Cache_Plugin\Includes\Plugin' ) ) {
+ WP_Rest_Cache::init();
+ }
+
/**
* Load the Surge integration.
*
@@ -161,6 +165,8 @@ function register_stream_connector( $classes ) {
add_filter(
'wp_stream_posts_exclude_post_types',
function ( $post_types ) {
+ $post_types[] = 'ap_actor';
+ // @todo remove in one of the next versions
$post_types[] = 'ap_follower';
$post_types[] = 'ap_extrafield';
$post_types[] = 'ap_extrafield_blog';
diff --git a/package.json b/package.json
index d12b6ed2b..18b27b69a 100644
--- a/package.json
+++ b/package.json
@@ -1,50 +1,55 @@
{
- "name": "wordpress-activitypub",
- "description": "The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/automattic/wordpress-activitypub.git"
- },
- "author": {
- "name": "Matthias Pfefferle",
- "web": "https://notiz.blog"
- },
- "scripts": {
- "dev": "wp-scripts start",
- "build": "wp-scripts format && wp-scripts build",
- "format": "wp-scripts format",
- "lint:css": "wp-scripts lint-style",
- "lint:js": "wp-scripts lint-js",
- "env": "wp-env",
- "env-start": "wp-env start && wp-env run cli wp rewrite structure '/%year%/%monthnum%/%postname%/'",
- "env-stop": "wp-env stop",
- "env-test": "wp-env run tests-cli --env-cwd=\"wp-content/plugins/activitypub\" vendor/bin/phpunit",
- "release": "node bin/release.js"
- },
- "license": "MIT",
- "bugs": {
- "url": "https://github.com/automattic/wordpress-activitypub/issues"
- },
- "homepage": "https://github.com/automattic/wordpress-activitypub#readme",
- "devDependencies": {
- "@wordpress/api-fetch": "^7.22.0",
- "@wordpress/block-editor": "^14.17.0",
- "@wordpress/blocks": "^14.0.0",
- "@wordpress/components": "^29.1.1",
- "@wordpress/compose": "^7.22.0",
- "@wordpress/core-data": "^7.22.0",
- "@wordpress/data": "^10.0.0",
- "@wordpress/dom-ready": "^4.0.0",
- "@wordpress/editor": "^14.22.0",
- "@wordpress/element": "^6.0.0",
- "@wordpress/env": "^10.10.0",
- "@wordpress/i18n": "^5.22.0",
- "@wordpress/icons": "^10.10.0",
- "@wordpress/plugins": "^7.22.0",
- "@wordpress/prettier-config": "^4.23.0",
- "@wordpress/primitives": "^4.22.0",
- "@wordpress/scripts": "^27.0.0",
- "@wordpress/url": "^4.22.0",
- "classnames": "^2.3.2"
- }
+ "name": "wordpress-activitypub",
+ "description": "The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/automattic/wordpress-activitypub.git"
+ },
+ "author": {
+ "name": "Matthias Pfefferle",
+ "web": "https://notiz.blog"
+ },
+ "scripts": {
+ "dev": "wp-scripts start --experimental-modules",
+ "build": "wp-scripts format && wp-scripts build --experimental-modules",
+ "format": "wp-scripts format",
+ "lint:css": "wp-scripts lint-style",
+ "lint:js": "wp-scripts lint-js",
+ "env": "wp-env",
+ "env-start": "wp-env start && wp-env run cli wp rewrite structure '/%year%/%monthnum%/%postname%/'",
+ "env-stop": "wp-env stop",
+ "env-test": "wp-env run tests-cli --env-cwd=\"wp-content/plugins/activitypub\" vendor/bin/phpunit",
+ "release": "node bin/release.js"
+ },
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/automattic/wordpress-activitypub/issues"
+ },
+ "homepage": "https://github.com/automattic/wordpress-activitypub#readme",
+ "devDependencies": {
+ "@wordpress/api-fetch": "^7.23.0",
+ "@wordpress/block-editor": "^14.17.0",
+ "@wordpress/blocks": "^14.0.0",
+ "@wordpress/components": "^29.1.1",
+ "@wordpress/compose": "^7.22.0",
+ "@wordpress/core-data": "^7.22.0",
+ "@wordpress/data": "^10.0.0",
+ "@wordpress/dom-ready": "^4.0.0",
+ "@wordpress/edit-post": "^8.22.0",
+ "@wordpress/editor": "^14.22.0",
+ "@wordpress/element": "^6.0.0",
+ "@wordpress/env": "^10.10.0",
+ "@wordpress/i18n": "^5.22.0",
+ "@wordpress/icons": "^10.10.0",
+ "@wordpress/interactivity": "^6.23.0",
+ "@wordpress/plugins": "^7.22.0",
+ "@wordpress/prettier-config": "^4.23.0",
+ "@wordpress/primitives": "^4.22.0",
+ "@wordpress/scripts": "^27.0.0",
+ "@wordpress/url": "^4.22.0",
+ "classnames": "^2.3.2",
+ "fast-glob": "^3.3.3",
+ "prettier": "npm:wp-prettier@^3.0.3",
+ "webpack-remove-empty-scripts": "^1.0.4"
+ }
}
diff --git a/phpcs.xml b/phpcs.xml
index 2326eeb42..48e03d9f3 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -17,7 +17,7 @@
-
+
diff --git a/readme.txt b/readme.txt
index 99eda447a..9084b1447 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,9 +1,9 @@
=== ActivityPub ===
Contributors: automattic, pfefferle, mattwiebe, obenland, akirk, jeherve, mediaformat, nuriapena, cavalierlife, andremenrath
-Tags: OStatus, fediverse, activitypub, activitystream
-Requires at least: 6.4
+Tags: fediverse, activitypub, indieweb, activitystream, social web
+Requires at least: 6.5
Tested up to: 6.8
-Stable tag: 5.9.0
+Stable tag: 7.0.1
Requires PHP: 7.2
License: MIT
License URI: http://opensource.org/licenses/MIT
@@ -16,7 +16,7 @@ Enter the fediverse with **ActivityPub**, broadcasting your blog to a wider audi
https://www.youtube.com/watch?v=QzYozbNneVc
-With the ActivityPub plugin installed, your WordPress blog itself function as a federated profile, along with profiles for each author. For instance, if your website is `example.com`, then the blog-wide profile can be found at `@example.com@example.com`, and authors like Jane and Bob would have their individual profiles at `@jane@example.com` and `@bobz@example.com`, respectively.
+With the ActivityPub plugin installed, your WordPress blog itself functions as a federated profile, along with profiles for each author. For instance, if your website is `example.com`, then the blog-wide profile can be found at `@example.com@example.com`, and authors like Jane and Bob would have their individual profiles at `@jane@example.com` and `@bobz@example.com`, respectively.
An example: I give you my Mastodon profile name: `@pfefferle@mastodon.social`. You search, see my profile, and hit follow. Now, any post I make appears in your Home feed. Similarly, with the ActivityPub plugin, you can find and follow Jane's profile at `@jane@example.com`.
@@ -59,38 +59,20 @@ This plugin connects your WordPress blog to popular social platforms like Mastod
= What is "ActivityPub for WordPress" =
-*ActivityPub for WordPress* extends WordPress with some Fediverse features, but it does not compete with platforms like Friendica or Mastodon. If you want to run a **decentralized social network**, please use [Mastodon](https://joinmastodon.org/) or [GNU social](https://gnusocial.network/).
+*ActivityPub for WordPress* adds Fediverse features to WordPress, but it is not a replacement for platforms like Friendica or Mastodon. If you're looking to host a decentralized social network, consider using [Mastodon](https://joinmastodon.org/) or [Friendica](https://friendi.ca/).
-= What if you are running your blog in a subdirectory? =
+= Why "ActivityPub"? =
-In order for webfinger to work, it must be mapped to the root directory of the URL on which your blog resides.
+The name ActivityPub comes from the two core ideas behind the protocol:
-**Apache**
+* Activity: It is based on the concept of activities, like "Create", "Like", "Follow", "Announce", etc. These are structured messages (usually in [ActivityStreams](https://www.w3.org/TR/activitystreams-core/) format) that describe what users do on the network.
+* Pub: Short for publish or publication. It refers to the fact that this is a publish-subscribe (pub-sub) protocol — one user can "follow" another, and receive their published activities.
-Add the following to the .htaccess file in the root directory:
+Put together, ActivityPub is a protocol for publishing and subscribing to activities, which enables decentralized social networking — where different servers can interact and users can follow each other across the Fediverse.
- RedirectMatch "^\/\.well-known/(webfinger|nodeinfo)(.*)$" /blog/.well-known/$1$2
+= How do I solve… =
-Where 'blog' is the path to the subdirectory at which your blog resides.
-
-**Nginx**
-
-Add the following to the site.conf in sites-available:
-
- location ~* /.well-known {
- allow all;
- try_files $uri $uri/ /blog/?$args;
- }
-
-Where 'blog' is the path to the subdirectory at which your blog resides.
-
-If you are running your blog in a subdirectory, but have a different [wp_siteurl](https://wordpress.org/documentation/article/giving-wordpress-its-own-directory/), you don't need the redirect, because the index.php will take care of that.
-
-= What if you are running your blog behind a reverse proxy with Apache? =
-
-If you are using a reverse proxy with Apache to run your host you may encounter that you are unable to have followers join the blog. This will occur because the proxy system rewrites the host headers to be the internal DNS name of your server, which the plugin then uses to attempt to sign the replies. The remote site attempting to follow your users is expecting the public DNS name on the replies. In these cases you will need to use the 'ProxyPreserveHost On' directive to ensure the external host name is passed to your internal host.
-
-If you are using SSL between the proxy and internal host you may also need to `SSLProxyCheckPeerName off` if your internal host can not answer with the correct SSL name. This may present a security issue in some environments.
+We have a **How-To** section in the [docs](https://github.com/Automattic/wordpress-activitypub/tree/trunk/docs/how-to) directory that can help you troubleshoot common issues.
= Constants =
@@ -128,296 +110,65 @@ For reasons of data protection, it is not possible to see the followers of other
== Changelog ==
-### 5.9.0 - 2025-05-14
-#### Added
-- ActivityPub embeds now support audios, videos, and up to 4 images.
-- Added a check to make sure we only attempt to embed activity objects, when processing fallback embeds.
-- Add setting to enable or disable how content is tailored for browsers and Fediverse services.
-- Adjusted the plugin's default behavior based on the caching plugins installed.
-- A guided onboarding flow after plugin activation to help users make key setup decisions and understand Fediverse concepts.
-- Author profiles will cap the amount of extra fields they return to 20, to avoid response size errors in clients.
-- Fediverse Preview in the Editor now also supports video and audio attachments.
-- Guidance for configuring Surge to support ActivityPub caching.
-- Help tab section explaining ActivityPub capabilities on the users page.
-- Profile sections have been moved from the Welcome page to new Dashboard widgets for easier access.
-- The ActivityPub blog news feed to WordPress dashboard.
-- The Outbox now skips invalid items instead of trying to process them for output and encountering an error.
-
-#### Changed
-- Batch processing jobs can now be scheduled with individual hooks.
-- Better error handling when other servers request Outbox items in the wrong format, and 404 pages now show correctly.
-- Fediverse Previews in the Block Editor now show media items, even if the post has not been published yet.
-- Hide interaction buttons in emails when the Classic Editor is used.
-- Improve compatibility with third-party caching plugins by sending a `Vary` header.
-- Much more comprehensive plugin documentation in the Help tab of ActivityPub Settings.
-- NodeInfo endpoint response now correctly formats `localPosts` values.
-- Reactions block heading now uses Core's heading block with all its customization options.
-- Settings pages are now more mobile-friendly with more space and easier scrolling.
-- The number of images shared to the Fediverse can now be chosen on a per-post basis.
-- Updated default max attachment count to four, creating better-looking gallery grids for posts with 4 or more images.
-- Use a dedicated hook for the "Dismiss Welcome Page Welcome" link.
-- Use FEP-c180 schema for error responses.
-- Use `Audio` and `Video` type for Attachments, instead of the very generic `Document` type.
-
-#### Deprecated
-- Deprecated `rest_activitypub_outbox_query` filter in favor of `activitypub_rest_outbox_query`.
- Deprecated `activitypub_outbox_post` action in favor of `activitypub_rest_outbox_post`.
-
-#### Fixed
-- Broken avatars in the Reactions and Follower block are now replaced with the default avatar.
-- Email notifications for interactions with Brid.gy actors no longer trigger PHP Warnings.
-- Improved support for users from more Fediverse platforms in email notifications.
-- Improved the handling of Shares and Boosts.
-- Issue preventing "Receive reblogs (boosts)" setting from being properly saved.
-- Mention emails will no longer be sent for reply Activities.
-- Prevent accidental follower removal by resetting errors properly.
-- Properly remove retries schedules, with the invalidation of an Outbox-Item.
-- The blog profile can no longer be queried when the blog actor option is disabled.
-
-### 5.8.0 - 2025-04-24
-#### Added
-- An option to receive notification emails when an Actor was mentioned in the Fediverse.
-- Enable direct linking to Help Tabs.
-- Fallback embed support for Fediverse content that lacks native oEmbed responses.
-- Support for all media types in the Mastodon Importer.
-
-#### Changed
-- Added WordPress disallowed list filtering to block unwanted ActivityPub interactions.
-- Mastodon imports now support blocks, with automatic reply embedding for conversations.
-- Tested and compatible with the latest version of WordPress.
-- Updated design of new follower notification email and added meta information.
-- Update DM email notification to include an embed display of the DM.
-- Updated notification settings to be user-specific for more personalization.
-
-#### Fixed
-- Add support for Multisite Language Switcher
-- Better check for an empty `headers` array key in the Signature class.
-- Include user context in Global-Inbox actions.
-- No more PHP warning when Mastodon Apps run out of posts to process.
-- Reply links and popup modals are now properly translated for logged-out visitors.
-
-### 5.7.0 - 2025-04-11
-#### Added
-- Advanced Settings tab, with special settings for advanced users.
-- Check if pretty permalinks are enabled and recommend to use threaded comments.
-- Reply block: show embeds where available.
-- Support same-server domain migrations.
-- Upgrade routine that removes any erroneously created extra field entries.
-
-#### Changed
-- Add option to enable/disable the "shared inbox" to the "Advanced Settings".
-- Add option to enable/disable the `Vary` Header to the "Advanced Settings".
-- Configure the "Follow Me" button to have a button-only mode.
-- Importers are loaded on admin-specific hook.
-- Improve the troubleshooting UI and show Site-Health stats in ActivityPub settings.
-- Increased compatibility with Mobilizon and other platforms by improving signature verification for different key formats.
-
-#### Fixed
-- Ensure that an `Activity` has an `Actor` before adding it to the Outbox.
-- Fixed some bugs and added additional information on the Debug tab of the Site-Health page.
-- Follow-up to the reply block changes that makes sure Mastodon embeds are displayed in the editor.
-- Outbox endpoint bug where non-numeric usernames caused errors when querying Outbox data.
-- Show Site Health error if site uses old "Almost Pretty Permalinks" structure.
-- Sites with comments from the Fediverse no longer create uncached extra fields posts that flood the Outbox.
-- Transformers allow settings values to false again, a regression from 5.5.0.
-
-### 5.6.1 - 2025-04-02
-#### Fixed
-- "Post Interactions" settings will now be saved to the options table.
-- So not show `movedTo` attribute instead of setting it to `false` if empty.
-- Use specified date format for `updated` field in Outbox-Activites.
-
-### 5.6.0 - 2025-04-01
-#### Added
-- Added a Mastodon importer to move your Mastodon posts to your WordPress site.
-- A default Extra-Field to do a little advertising for WordPress.
-- Move: Differentiate between `internal` and 'external' Move.
-- Redirect user to the welcome page after ActivityPub plugin is activated.
-- The option to show/hide the "Welcome Page".
-- User setting to enable/disable Likes and Reblogs
-
-#### Changed
-- Logged-out remote reply button markup to look closer to logged-in version.
-- No longer federates `Delete` activities for posts that were not federated.
-- OrderedCollection and OrderedCollectionPage behave closer to spec now.
-- Outbox items now contain the full activity, not just activity objects.
-- Standardized mentions to use usernames only in comments and posts.
-
+### 7.0.1 - 2025-07-10
#### Fixed
-- Changelog entries: allow automating changelog entry generation from forks as well.
-- Comments from Fediverse actors will now be purged as expected.
-- Importing attachments no longer creates Outbox items for them.
-- Improved readability in Mastodon Apps plugin string.
-- No more PHP warnings when previewing posts without attachments.
-- Outbox batch processing adheres to passed batch size.
-- Permanently delete reactions that were `Undo` instead of trashing them.
-- PHP warnings when scheduling post activities for an invalid post.
-- PHP Warning when there's no actor information in comment activities.
-- Prevent self-replies on local comments.
-- Properly set `to` audience of `Activity` instead of changing the `Follow` Object.
-- Run all Site-Health checks with the required headers and a valid signature.
-- Set `updated` field for profile updates, otherwise the `Update`-`Activity` wouldn't be handled by Mastodon.
-- Support multiple layers of nested Outbox activities when searching for the Object ID.
-- The Custom-Avatar getter on WP.com.
-- Use the $from account for the object in Move activity for external Moves
-- Use the `$from` account for the object in Move activity for internal Moves
-- Use `add_to_outbox` instead of the changed scheduler hooks.
-- Use `JSON_UNESCAPED_SLASHES` because Mastodon seems to have problems with encoded URLs.
-- `Scheduler::schedule_announce_activity` to handle Activities instead of Activity-Objects.
-
-### 5.5.0 - 2025-03-19
-#### Added
-- Added "Enable Mastodon Apps" and "Event Bridge for ActivityPub" to the recommended plugins section.
-- Added Constants to the Site-Health debug informations.
-- Development environment: add Changelogger tool to environment dependencies.
-- Development environment: allow contributors to specify a changelog entry directly from their Pull Request description.
-- Documentation for migrating from a Mastodon instance to WordPress.
-- Support for sending Activities to ActivityPub Relays, to improve discoverability of public content.
-
-#### Changed
-- Documentation: expand Pull Request process docs, and mention the new changelog process as well as the updated release process.
-- Don't redirect @-name URLs to trailing slashed versions
-- Improved and simplified Query code.
-- Improved readability for actor mode setting.
-- Improved title case for NodeInfo settings.
-- Introduced utility function to determine actor type based on user ID.
-- Outbox items only get sent to followers when there are any.
-- Restricted modifications to settings if they are predefined as constants.
-- The Welcome page now uses WordPress's Settings API and the classic design of the WP Admin.
-- Uses two-digit version numbers in Outbox and NodeInfo responses.
-
-#### Removed
-- Our version of `sanitize_url()` was unused—use Core's `sanitize_url()` instead.
-
-#### Fixed
-- Ensured that Query::get_object_id() returns an ID instead of an Object.
-- Fix a fatal error in the Preview when a post contains no (hash)tags.
-- Fixed an issue with the Content Carousel and Blog Posts block: https://github.com/Automattic/wp-calypso/issues/101220
-- Fixed default value for `activitypub_authorized_fetch` option.
-- Follow-Me blocks now show the correct avatar on attachment pages.
-- Images with the correct aspect ratio no longer get sent through the crop step again.
-- No more PHP warnings when a header image gets cropped.
-- PHP warnings when trying to process empty tags or image blocks without ID attributes.
-- Properly re-added support for `Update` and `Delete` `Announce`ments.
-- Updates to certain user meta fields did not trigger an Update activity.
-- When viewing Reply Contexts, we'll now attribute the post to the blog user when the post author is disabled.
-
-### 5.4.1 - 2025-03-04
-#### Fixed
-- Fixed transition handling of posts to ensure that `Create` and `Update` activities are properly processed.
-- Show "full content" preview even if post is in still in draft mode.
-
-### 5.4.0 - 2025-03-03
-#### Added
-- Upgrade script to fix Follower json representations with unescaped backslashes.
-- Centralized place for sanitization functions.
-
-#### Changed
-- Bumped minimum required WordPress version to 6.4.
-- Use a later hook for Posts to get published to the Outbox, to get sure all `post_meta`s and `taxonomy`s are set stored properly.
-- Use webfinger as author email for comments from the Fediverse.
-- Remove the special handling of comments from Enable Mastodon Apps.
-
-#### Fixed
-- Do not redirect `/@username` URLs to the API any more, to improve `AUTHORIZED_FETCH` handling.
-
-### 5.3.2 - 2025-02-27
-#### Fixed
-- Remove `activitypub_reply_block` filter after Activity-JSON is rendered, to not affect the HTML representation.
-- Remove `render_block_core/embed` filter after Activity-JSON is rendered, to not affect the HTML representation.
-
-### 5.3.1 - 2025-02-26
-#### Fixed
-- Blog profile settings can be saved again without errors.
-- Followers with backslashes in their descriptions no longer break their actor representation.
-
-### 5.3.0 - 2025-02-25
-#### Added
-- A fallback `Note` for `Article` objects to improve previews on services that don't support Articles yet.
-- A reply `context` for Posts and Comments to allow relying parties to discover the whole conversation of a thread.
-- Setting to adjust the number of days Outbox items are kept before being purged.
-- Failed Follower notifications for Outbox items now get retried for two more times.
-- Undo API for Outbox items.
-- Metadata to New Follower E-Mail.
-- Allow Activities on URLs instead of requiring Activity-Objects. This is useful especially for sending Announces and Likes.
-- Outbox Activity IDs can now be resolved when the ActivityPub `Accept header is used.
-- Support for incoming `Move` activities and ensure that followed persons are updated accordingly.
-- Labels to add context to visibility settings in the block editor.
-- WP CLI command to reschedule Outbox-Activities.
-
-#### Changed
-- Outbox now precesses the first batch of followers right away to avoid delays in processing new Activities.
-- Post bulk edits no longer create Outbox items, unless author or post status change.
-- Properly process `Update` activities on profiles and ensure all properties of a followed person are updated accordingly.
-- Outbox processing accounts for shared inboxes again.
-- Improved check for `?activitypub` query-var.
-- Rewrite rules: be more specific in author rewrite rules to avoid conflicts on sites that use the "@author" pattern in their permalinks.
-- Deprecate the `activitypub_post_locale` filter in favor of the `activitypub_locale` filter.
+- When deleting interactions for cleaned up actors, we use the actor's URL again to retrieve their information instead of our internal ID.
-#### Fixed
-- The Outbox purging routine no longer is limited to deleting 5 items at a time.
-- Ellipses now display correctly in notification emails for Likes and Reposts.
-- Send Update-Activity when "Actor-Mode" is changed.
-- Added delay to `Announce` Activity from the Blog-Actor, to not have race conditions.
-- `Actor` validation in several REST API endpoints.
-- Bring back the `activitypub_post_locale` filter to allow overriding the post's locale.
-
-### 5.2.0 - 2025-02-13
+### 7.0.0 - 2025-07-09
#### Added
-- Batch Outbox-Processing.
-- Outbox processed events get logged in Stream and show any errors returned from inboxes.
-- Outbox items older than 6 months will be purged to avoid performance issues.
-- REST API endpoints for likes and shares.
-
-#### Changed
-- Increased probability of Outbox items being processed with the correct author.
-- Enabled querying of Outbox posts through the REST API to improve troubleshooting and debugging.
-- Updated terminology to be client-neutral in the Federated Reply block.
-
-#### Fixed
-- Fixed an issue where the outbox could not send object types other than `Base_Object` (introduced in 5.0.0).
-- Enforce 200 status header for valid ActivityPub requests.
-- `object_id_to_comment` returns a commment now, even if there are more than one matching comment in the DB.
-- Integration of content-visibility setup in the block editor.
-- Update CLI commands to the new scheduler refactorings.
-- Do not add an audience to the Actor-Profiles.
-- `Activity::set_object` falsely overwrites the Activity-ID with a default.
-
-### 5.1.0 - 2025-02-06
-#### Added
-- Cleanup of option values when the plugin is uninstalled.
-- Third-party plugins can filter settings tabs to add their own settings pages for ActivityPub.
-- Show ActivityPub preview in row actions when Block Editor is enabled but not used for the post type.
-
-#### Changed
-- Manually granting `activitypub` cap no longer requires the receiving user to have `publish_post`.
-- Allow omitting replies in ActivityPub representations instead of setting them as empty.
-- Allow Base Transformer to handle WP_Term objects for transformation.
-- Improved Query extensibility for third party plugins.
-
-#### Fixed
-- Negotiation of ActivityPub requests for custom post types when queried by the ActivityPub ID.
-- Avoid PHP warnings when using Debug mode and when the `actor` is not set.
-- No longer creates Outbox items when importing content/users.
-- Fix NodeInfo 2.0 URL to be HTTP instead of HTTPS.
+- Added basic support for handling remote rejections of follow requests.
+- Added basic support for RFC-9421 style signatures for incoming activities.
+- Added initial Following support for Actors, hidden for now until plugins add support.
+- Added missing "Advanced Settings" details to Site Health debug information.
+- Added option to auto-approve reactions like likes and reposts.
+- Added support for namespaced attributes and the dcterms:subject field (FEP-b2b8), as a first step toward phasing out summary-based content warnings.
+- Added support for the WP Rest Cache plugin to help with caching REST API responses.
+- Documented support for FEP-844e.
+- Optional support for RFC-9421 style signatures for outgoing activities, including retry with Draft-Cavage-style signature.
+- Reactions block now supports customizing colors, borders, box-shadows, and typography.
+- Support for sending follow requests to remote actors is now in place, including outbox delivery and status updates—UI integration will follow later.
-### 5.0.0 - 2025-02-03
#### Changed
-- Improved content negotiation and AUTHORIZED_FETCH support for third-party plugins.
-- Moved password check to `is_post_disabled` function.
+- Comment feeds now show only comments by default, with a new `type` filter (e.g., `like`, `all`) to customize which reactions appear.
+- Consistent naming of Blog user in Block settings.
+- hs2019 signatures for incoming REST API requests now have their algorithm determined based on their public key.
+- Likes, comments, and reposts from the Fediverse now require either a name or `preferredUsername` to be set when the Discussion option `require_name_email` is set to true. It falls back to "Anonymous", if not.
+- Management of public/private keys for Actors now lives in the Actors collection, in preparation for Signature improvements down the line.
+- Notification emails for new reactions received from the Fediverse now link to the moderation page instead of the edit page, preventing errors and making comment management smoother.
+- Plugins now have full control over which Settings tabs are shown in Settings > Activitypub.
+- Reworked follower structure to simplify handling and enable reuse for following mechanism.
+- Screen options in the Activitypub settings page are now filterable.
+- Setting the blog identifier to empty will no longer trigger an error message about it being the same as an existing user name.
+- Step completion tracking in the Welcome tab now even works when the number of steps gets reduced.
+- The image attachment setting is no longer saved to the database if it matches the default value.
+- The welcome page now links to the correct profile when Blog Only mode was selected in the profile mode step.
+- Unified retrieval of comment avatars and re-used core filters to give access to third-part plugins.
#### Fixed
-- Handle deletes from remote servers that leave behind an accessible Tombstone object.
-- No longer parses tags for post types that don't support Activitypub.
-- rel attribute will now contain no more than one "me" value.
+- Allow interaction redirect URLs that contain an ampersand.
+- Comments received from the Fediverse no longer show an Edit link in the comment list, despite not being editable.
+- Fixed an issue where links to remote likes and boosts could open raw JSON instead of a proper page.
+- Fixed a potential error when getting an Activitypub ID based on a user ID.
+- HTTP signatures using the hs2019 algorithm now get accepted without error.
+- Improved compatibility with older follower data.
+- Inbox requests that are missing an `algorithm` parameter in their signature no longer create a PHP warning.
+- Interaction attempts that pass a webfinger ID instead of a URL will work again.
+- Names containing HTML entities now get displayed correctly in the Reactions block's list of users.
+- Prevent storage of empty or default post meta values.
+- The amount of avatars shown in the Reactions block no longer depends on the amount of likes, but is comment type agnostic.
+- The command-line interface extension, accidentally removed in a recent cleanup, has been restored.
+- The image attachment setting now correctly respects a value of 0, instead of falling back to the default.
+- The Welcome screen now loads with proper styling when shown as a fallback.
+- Using categories as hashtags has been removed to prevent conflicts with tags of the same name.
+- When verifying signatures on incoming requests, the digest header now gets checked as expected.
See full Changelog on [GitHub](https://github.com/Automattic/wordpress-activitypub/blob/trunk/CHANGELOG.md).
== Upgrade Notice ==
-= 5.9.0 =
+= 7.0.0 =
-Experience our new onboarding flow and improved help docs—making it easier than ever to connect your site to the Fediverse!
+HTTP signatures now accept modern RFC 9421 standard with fallback and verification.
== Installation ==
diff --git a/src/blocks/editor-plugin/block.json b/src/blocks/editor-plugin/block.json
new file mode 100644
index 000000000..119a62d64
--- /dev/null
+++ b/src/blocks/editor-plugin/block.json
@@ -0,0 +1,8 @@
+{
+ "name": "editor-plugin",
+ "title": "Editor Plugin: not a block, but block.json is very useful.",
+ "category": "widgets",
+ "icon": "admin-comments",
+ "keywords": [],
+ "editorScript": "file:./plugin.js"
+}
diff --git a/src/editor-plugin/plugin.js b/src/blocks/editor-plugin/plugin.js
similarity index 61%
rename from src/editor-plugin/plugin.js
rename to src/blocks/editor-plugin/plugin.js
index b1417e97d..fa88eecca 100644
--- a/src/editor-plugin/plugin.js
+++ b/src/blocks/editor-plugin/plugin.js
@@ -1,4 +1,5 @@
-import { PluginDocumentSettingPanel, PluginPreviewMenuItem } from '@wordpress/editor';
+import { PluginDocumentSettingPanel, PluginPreviewMenuItem, store as editorStore } from '@wordpress/editor';
+import { PluginDocumentSettingPanel as DocumentSettingPanel } from '@wordpress/edit-post';
import { registerPlugin } from '@wordpress/plugins';
import { TextControl, RadioControl, RangeControl, __experimentalText as Text, Tooltip } from '@wordpress/components';
import { Icon, globe, people, external } from '@wordpress/icons';
@@ -8,27 +9,37 @@ import { addQueryArgs } from '@wordpress/url';
import { __ } from '@wordpress/i18n';
import { SVG, Path } from '@wordpress/primitives';
-// Defining our own because it's too new in @wordpress/icons
-// https://github.com/WordPress/gutenberg/blob/trunk/packages/icons/src/library/not-allowed.js
-const notAllowed = (
-
-
-
-);
-
/**
* Editor plugin for ActivityPub settings in the block editor.
*
- * @returns {JSX.Element|null} The settings panel for ActivityPub or null for sync blocks.
+ * @returns {React.JSX.Element|null} The settings panel for ActivityPub or null for sync blocks.
*/
const EditorPlugin = () => {
- const postType = useSelect( ( select ) => select( 'core/editor' ).getCurrentPostType(), [] );
+ const postType = useSelect( ( select ) => select( editorStore ).getCurrentPostType(), [] );
const [ meta, setMeta ] = useEntityProp( 'postType', postType, 'meta' );
+ // Don't show when editing sync blocks.
+ if ( 'wp_block' === postType ) {
+ return null;
+ }
+
+ /**
+ * SVG for the not-allowed icon. Defining our own because it's too new in @wordpress/icons.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/trunk/packages/icons/src/library/not-allowed.js
+ *
+ * @var {React.JSX.Element} notAllowed The SVG for the not-allowed icon.
+ */
+ const notAllowed = (
+
+
+
+ );
+
const labelStyling = {
verticalAlign: 'middle',
gap: '4px',
@@ -40,11 +51,11 @@ const EditorPlugin = () => {
/**
* Enhances a label with an icon and tooltip.
*
- * @param {JSX.Element} icon The icon to display.
- * @param {string} text The label text.
- * @param {string} tooltip The tooltip text.
+ * @param {React.JSX.Element} icon The icon to display.
+ * @param {string} text The label text.
+ * @param {string} tooltip The tooltip text.
*
- * @returns {JSX.Element} The enhanced label component.
+ * @returns {React.JSX.Element} The enhanced label component.
*/
const enhancedLabel = ( icon, text, tooltip ) => (
@@ -55,13 +66,18 @@ const EditorPlugin = () => {
);
- // Don't show when editing sync blocks.
- if ( 'wp_block' === postType ) {
- return null;
- }
+ /*
+ * Backwards compatibility with WordPress 6.5.
+ * @todo Remove when 6.5 is no longer supported.
+ */
+ const SettingsPanel = PluginDocumentSettingPanel || DocumentSettingPanel;
return (
-
+
{
'Content warnings do not change the content on your site, only in the fediverse.',
'activitypub'
) }
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
/>
{
setMeta( { ...meta, activitypub_max_image_attachments: value } );
} }
@@ -89,6 +105,8 @@ const EditorPlugin = () => {
'Maximum number of image attachments to include when sharing to the fediverse.',
'activitypub'
) }
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
/>
{
} }
className="activitypub-visibility"
/>
-
+
);
};
-/**
- * Opens the Fediverse preview for the current post in a new tab.
- */
-function onActivityPubPreview() {
- const previewLink = select( 'core/editor' ).getEditedPostPreviewLink();
- const fediversePreviewLink = addQueryArgs( previewLink, { activitypub: 'true' } );
-
- window.open( fediversePreviewLink, '_blank' );
-}
-
/**
* Renders the preview menu item for Fediverse preview.
*
- * @returns {JSX.Element} The preview menu item component.
+ * @returns {React.JSX.Element} The preview menu item component.
*/
const EditorPreview = () => {
- // check if post was saved
- const post_status = useSelect( ( select ) => select( 'core/editor' ).getCurrentPost().status );
+ const post_status = useSelect( ( select ) => select( editorStore ).getCurrentPost().status, [] );
+
+ /**
+ * Opens the Fediverse preview for the current post in a new tab.
+ */
+ const onActivityPubPreview = () => {
+ const previewLink = select( editorStore ).getEditedPostPreviewLink();
+ const fediversePreviewLink = addQueryArgs( previewLink, { activitypub: 'true' } );
+
+ window.open( fediversePreviewLink, '_blank' );
+ };
return (
<>
{ PluginPreviewMenuItem ? (
onActivityPubPreview() }
+ onClick={ onActivityPubPreview }
icon={ external }
disabled={ post_status === 'auto-draft' }
>
diff --git a/src/follow-me/block.json b/src/blocks/follow-me/block.json
similarity index 61%
rename from src/follow-me/block.json
rename to src/blocks/follow-me/block.json
index 2abe9de5d..c649963af 100644
--- a/src/follow-me/block.json
+++ b/src/blocks/follow-me/block.json
@@ -2,14 +2,20 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/follow-me",
"apiVersion": 3,
- "version": "1.0.0",
+ "version": "2.2.0",
"title": "Follow me on the Fediverse",
"category": "widgets",
"description": "Display your Fediverse profile so that visitors can follow you.",
"textdomain": "activitypub",
"icon": "groups",
+ "example": {
+ "attributes": {
+ "className": "is-style-default"
+ }
+ },
"supports": {
"html": false,
+ "interactivity": true,
"color": {
"gradients": true,
"link": true,
@@ -25,34 +31,42 @@
"color": true,
"style": true
},
+ "shadow": true,
"typography": {
"fontSize": true,
"__experimentalDefaultControls": {
"fontSize": true
}
+ },
+ "innerBlocks": {
+ "allowedBlocks": [ "core/button" ]
}
},
- "attributes": {
- "selectedUser": {
- "type": "string",
- "default": "site"
+ "styles": [
+ {
+ "name": "default",
+ "label": "Default",
+ "isDefault": true
},
- "buttonOnly": {
- "type": "boolean",
- "default": false
+ {
+ "name": "button-only",
+ "label": "Button"
},
- "buttonText": {
- "type": "string",
- "default": "Follow"
- },
- "buttonSize": {
+ {
+ "name": "profile",
+ "label": "Profile"
+ }
+ ],
+ "attributes": {
+ "selectedUser": {
"type": "string",
- "default": "default",
- "enum": ["small", "default", "compact"]
+ "default": "blog"
}
},
"usesContext": [ "postType", "postId" ],
"editorScript": "file:./index.js",
- "viewScript": "file:./view.js",
- "style": ["file:./style-view.css", "wp-components"]
-}
\ No newline at end of file
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
+ "style": "file:./style-index.css",
+ "render": "file:./render.php"
+}
diff --git a/src/blocks/follow-me/button-style.js b/src/blocks/follow-me/button-style.js
new file mode 100644
index 000000000..b022cd08e
--- /dev/null
+++ b/src/blocks/follow-me/button-style.js
@@ -0,0 +1,164 @@
+/**
+ * Cache for computed styles and CSS variable checks.
+ */
+const cssCache = {
+ computedStyles: null,
+ variables: {},
+};
+
+/**
+ * Checks if a CSS variable is defined.
+ *
+ * Uses a caching mechanism to avoid frequent getComputedStyle calls,
+ * which can cause layout thrashing when called repeatedly.
+ *
+ * @param {string} variableName The CSS variable name to check.
+ * @return {boolean} Whether the variable is defined.
+ */
+function isCssVariableDefined( variableName ) {
+ // Return false if we're in a server-side context.
+ if ( typeof window === 'undefined' || ! window.getComputedStyle ) {
+ return false;
+ }
+
+ // Check if we've already cached this variable.
+ if ( cssCache.variables.hasOwnProperty( variableName ) ) {
+ return cssCache.variables[ variableName ];
+ }
+
+ // Get the computed style of the root element (cached).
+ if ( ! cssCache.computedStyles ) {
+ cssCache.computedStyles = window.getComputedStyle( document.documentElement );
+ }
+
+ // Get the value of the CSS variable.
+ const value = cssCache.computedStyles.getPropertyValue( variableName ).trim();
+
+ // Cache the result.
+ cssCache.variables[ variableName ] = value !== '';
+
+ // If the value is empty, the variable is not defined or is set to an empty value.
+ return cssCache.variables[ variableName ];
+}
+
+/**
+ * Gets the background color from a style object.
+ *
+ * @param {Object|string} color Color object or string.
+ * @return {string|null} Background color.
+ */
+function getBackgroundColor( color ) {
+ // If color is a string, it's a var like this.
+ if ( typeof color === 'string' ) {
+ const varName = `--wp--preset--color--${ color }`;
+ if ( ! isCssVariableDefined( varName ) ) {
+ return null;
+ }
+ return `var(${ varName })`;
+ }
+
+ return color?.color?.background || null;
+}
+
+/**
+ * Gets the link color from a style object.
+ *
+ * @param {string} text Text color.
+ * @return {string|null} Link color.
+ */
+function getLinkColor( text ) {
+ if ( typeof text !== 'string' ) {
+ return null;
+ }
+ // If it starts with a hash, leave it be.
+ if ( text.match( /^#/ ) ) {
+ // We don't handle the alpha channel if present.
+ return text.substring( 0, 7 );
+ }
+ // var:preset|color|luminous-vivid-amber
+ // var(--wp--preset--color--luminous-vivid-amber)
+ // We will receive the top format, we need to output the bottom format.
+ const [ , , color ] = text.split( '|' );
+ const varName = `--wp--preset--color--${ color }`;
+
+ // Check if the CSS variable is defined before using it.
+ if ( ! isCssVariableDefined( varName ) ) {
+ return null;
+ }
+
+ return `var(${ varName })`;
+}
+
+/**
+ * Generates a CSS selector.
+ *
+ * @param {string} selector CSS selector.
+ * @param {string} prop CSS property.
+ * @param {string|null} value CSS value.
+ * @param {string} pseudo Pseudo-selector.
+ * @return {string} CSS selector.
+ */
+function generateSelector( selector, prop, value = null, pseudo = '' ) {
+ if ( ! value ) {
+ return '';
+ }
+ return `${ selector }${ pseudo } { ${ prop }: ${ value }; }\n`;
+}
+
+/**
+ * Gets styles for a button.
+ *
+ * @param {string} selector CSS selector.
+ * @param {string} button Button color.
+ * @param {string} text Text color.
+ * @param {string} hover Hover color.
+ * @return {string} CSS styles.
+ */
+function getStyles( selector, button, text, hover ) {
+ return (
+ generateSelector( selector, 'background-color', button ) +
+ generateSelector( selector, 'color', text ) +
+ generateSelector( selector, 'background-color', hover, ':hover' ) +
+ generateSelector( selector, 'background-color', hover, ':focus' )
+ );
+}
+
+/**
+ * Gets block styles.
+ *
+ * @param {string} base Base selector.
+ * @param {Object} style Style object.
+ * @param {Object|string} backgroundColor Background color.
+ * @return {string} CSS styles.
+ */
+export function getBlockStyles( base, style, backgroundColor ) {
+ const selector = `${ base } .wp-block-button__link`;
+
+ // We grab the background color if set as a good color for our button text.
+ const buttonTextColor =
+ getBackgroundColor( backgroundColor ) ||
+ // Background might be in this form.
+ style?.color?.background;
+
+ // We misuse the link color for the button background.
+ const buttonColor = getLinkColor( style?.elements?.link?.color?.text );
+ const buttonHoverColor = getLinkColor( style?.elements?.link?.[ ':hover' ]?.color?.text );
+
+ return getStyles( selector, buttonColor, buttonTextColor, buttonHoverColor );
+}
+
+/**
+ * Gets popup styles.
+ *
+ * @param {Object} style Style object.
+ * @return {string} CSS styles.
+ */
+export function getPopupStyles( style ) {
+ // We don't accept backgroundColor because the popup is always white (right?).
+ const buttonColor = getLinkColor( style?.elements?.link?.color?.text ) || '#111';
+ const buttonTextColor = '#fff';
+ const buttonHoverColor = getLinkColor( style?.elements?.link?.[ ':hover' ]?.color?.text ) || '#333';
+ const selector = '.activitypub-dialog__button-group .wp-block-button';
+
+ return getStyles( selector, buttonColor, buttonTextColor, buttonHoverColor );
+}
diff --git a/src/blocks/follow-me/deprecation.js b/src/blocks/follow-me/deprecation.js
new file mode 100644
index 000000000..39a010f9b
--- /dev/null
+++ b/src/blocks/follow-me/deprecation.js
@@ -0,0 +1,208 @@
+import classnames from 'classnames';
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * The block supports for the Follow Me block in version 1.
+ *
+ * @type {{html: boolean, color: {gradients: boolean, link: boolean, __experimentalDefaultControls: {background: boolean, text: boolean, link: boolean}}, __experimentalBorder: {radius: boolean, width: boolean, color: boolean, style: boolean}, typography: {fontSize: boolean, __experimentalDefaultControls: {fontSize: boolean}}}}
+ */
+const v1BlockSupports = {
+ html: false,
+ color: {
+ gradients: true,
+ link: true,
+ __experimentalDefaultControls: {
+ background: true,
+ text: true,
+ link: true,
+ },
+ },
+ __experimentalBorder: {
+ radius: true,
+ width: true,
+ color: true,
+ style: true,
+ },
+ typography: {
+ fontSize: true,
+ __experimentalDefaultControls: {
+ fontSize: true,
+ },
+ },
+};
+
+const v2BlockSupports = v1BlockSupports;
+
+/**
+ * Migrates the buttonOnly attribute to a block style for the Follow Me block.
+ *
+ * @param {Object} attributes The block attributes.
+ * @return {Object} The migrated block attributes.
+ */
+function migrateButtonOnly( { buttonOnly = false, className = '', ...newAttributes } ) {
+ newAttributes.className = classnames( className, buttonOnly ? 'is-style-button-only' : 'is-style-default' );
+
+ return newAttributes;
+}
+
+/**
+ * Deprecation for the Follow Me block to use a core button block instead of the custom button.
+ * This handles the migration of the buttonText and buttonSize attributes to the innerBlock.
+ */
+const v1 = {
+ attributes: {
+ buttonOnly: {
+ type: 'boolean',
+ default: false,
+ },
+ buttonText: {
+ type: 'string',
+ default: 'Follow',
+ },
+ selectedUser: {
+ type: 'string',
+ default: 'blog',
+ },
+ },
+
+ supports: v1BlockSupports,
+
+ /**
+ * Checks if the block is eligible for migration.
+ *
+ * @param {Object} attributes The block attributes.
+ *
+ * @return {boolean} Whether the block is eligible for migration.
+ */
+ isEligible( { buttonText, buttonOnly } ) {
+ // Run migration if buttonText or buttonOnly is set.
+ return !! buttonText || !! buttonOnly;
+ },
+
+ /**
+ * Migrates the Follow Me block to use a core button block instead of the custom button.
+ *
+ * @param {Object} attributes The block attributes.
+ *
+ * @return {[Object, Array]} An array with the new block attributes and inner blocks.
+ */
+ migrate( { buttonText, ...newAttributes } ) {
+ const buttonBlock = createBlock( 'core/button', {
+ text: buttonText,
+ } );
+
+ return [ migrateButtonOnly( newAttributes ), [ buttonBlock ] ];
+ },
+};
+
+/**
+ * Deprecation for the Follow Me block.
+ * Handles the transition from using the buttonOnly attribute to using block styles.
+ */
+const v2 = {
+ attributes: {
+ selectedUser: {
+ type: 'string',
+ default: 'blog',
+ },
+ buttonOnly: {
+ type: 'boolean',
+ default: false,
+ },
+ },
+
+ supports: v2BlockSupports,
+
+ /**
+ * Checks if the block is eligible for migration.
+ *
+ * @param {Object} attributes The block attributes.
+ *
+ * @return {boolean} Whether the block is eligible for migration.
+ */
+ isEligible( { buttonOnly } ) {
+ return !! buttonOnly;
+ },
+
+ /**
+ * Migrates the Follow Me block to use a block style instead of the buttonOnly attribute.
+ *
+ * @param {Object} attributes The block attributes.
+ *
+ * @return {[Object, Array]} An array with the new block attributes and inner blocks.
+ */
+ migrate: migrateButtonOnly,
+
+ /**
+ * Save function for the Follow Me block.
+ *
+ * @return {JSX.Element} React element to save.
+ */
+ save() {
+ const blockProps = useBlockProps.save();
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps );
+
+ return
;
+ },
+};
+
+/**
+ * Deprecation for the Follow Me block.
+ * Handles the case where the button HTML is stripped due to unfiltered_html capability restrictions.
+ */
+const v3 = {
+ attributes: {
+ selectedUser: {
+ type: 'string',
+ default: 'blog',
+ },
+ },
+
+ supports: v2BlockSupports,
+
+ /**
+ * Checks if the block is eligible for migration.
+ *
+ * @param {Object} attributes The block attributes.
+ * @param {array} innerBlocks The inner blocks.
+ *
+ * @return {boolean} Whether the block is eligible for migration.
+ */
+ isEligible( attributes, innerBlocks ) {
+ return innerBlocks.length === 1 && 'button' === innerBlocks[ 0 ].attributes.tagName;
+ },
+
+ /**
+ * Migrates the Follow Me block to fix the broken button.
+ *
+ * @param {Object} attributes The block attributes.
+ * @param {array} innerBlocks The inner blocks.
+ *
+ * @return {[Object, Array]} An array with the new block attributes and inner blocks.
+ */
+ migrate( attributes, innerBlocks ) {
+ const { tagName, ...buttonAttributes } = innerBlocks[ 0 ].attributes;
+ const text = innerBlocks[ 0 ].originalContent.replace( /<[^>]*>/g, '' ) ?? __( 'Follow', 'activitypub' );
+
+ // Create a proper button block with the correct structure and the extracted text
+ const buttonBlock = createBlock( 'core/button', { ...buttonAttributes, text } );
+
+ return [ attributes, [ buttonBlock ] ];
+ },
+
+ /**
+ * Save function for the Follow Me block.
+ *
+ * @return {JSX.Element} React element to save.
+ */
+ save() {
+ const blockProps = useBlockProps.save();
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps );
+
+ return
;
+ },
+};
+
+export default [ v3, v2, v1 ];
diff --git a/src/blocks/follow-me/edit.js b/src/blocks/follow-me/edit.js
new file mode 100644
index 000000000..52110e5ed
--- /dev/null
+++ b/src/blocks/follow-me/edit.js
@@ -0,0 +1,252 @@
+import apiFetch from '@wordpress/api-fetch';
+import { InspectorControls, useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+import { __, _n } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { SelectControl, PanelBody } from '@wordpress/components';
+import { useEffect, useState } from '@wordpress/element';
+import { useUserOptions } from '../shared/use-user-options';
+import { InheritModeBlockFallback } from '../shared/inherit-block-fallback';
+import { useOptions } from '../shared/use-options';
+
+/**
+ * Default profile data.
+ *
+ * @type {Object}
+ */
+const DEFAULT_PROFILE_DATA = {
+ avatar: 'https://secure.gravatar.com/avatar/default?s=120',
+ webfinger: '@well@hello.dolly',
+ name: __( 'Hello Dolly Fan Account', 'activitypub' ),
+ url: '#',
+ image: { url: '' },
+ summary: '',
+};
+
+/**
+ * Get normalized profile data.
+ *
+ * @param {Object} profile Profile data.
+ * @return {Object} Normalized profile data.
+ */
+function getNormalizedProfile( profile ) {
+ if ( ! profile ) {
+ return DEFAULT_PROFILE_DATA;
+ }
+
+ const data = { ...DEFAULT_PROFILE_DATA, ...profile };
+ data.avatar = data?.icon?.url;
+
+ // Ensure webfinger always has the @ prefix.
+ if ( data.webfinger && ! data.webfinger.startsWith( '@' ) ) {
+ data.webfinger = '@' + data.webfinger;
+ }
+
+ return data;
+}
+
+/**
+ * Fetch profile data.
+ *
+ * @param {number} userId User ID.
+ * @return {Promise} Promise resolving with profile data.
+ */
+function fetchProfile( userId ) {
+ const { namespace } = useOptions();
+ const fetchOptions = {
+ headers: { Accept: 'application/activity+json' },
+ path: `/${ namespace }/actors/${ userId }`,
+ };
+ return apiFetch( fetchOptions );
+}
+
+/**
+ * Profile component for the editor.
+ *
+ * @param {Object} props Component props.
+ * @return {JSX.Element} Profile component.
+ */
+function EditorProfile( { profile, className, innerBlocksProps } ) {
+ const { webfinger, avatar, name, image, summary, followersCount, postsCount } = profile;
+
+ // Ensure we're checking for the right className format
+ const isButtonOnly = className && className.includes( 'is-style-button-only' );
+
+ // Stats for the editor preview - use real followers count if available
+ const stats = {
+ posts: postsCount || 0,
+ followers: followersCount || 0,
+ };
+
+ return (
+
+ { ! isButtonOnly && image?.url && (
+
+ ) }
+
+
+ { ! isButtonOnly &&
}
+
+
+ { ! isButtonOnly && (
+
+
{ name }
+
{ webfinger }
+
+ ) }
+
+
+
+ { ! isButtonOnly && (
+
+ ) }
+
+ { ! isButtonOnly && (
+
+ { Object.entries( stats ).map( ( [ key, count ] ) => (
+
+ { count } { ' ' }
+ { key === 'posts'
+ ? _n( 'post', 'posts', count, 'activitypub' )
+ : key === 'followers'
+ ? _n( 'follower', 'followers', count, 'activitypub' )
+ : _n( 'following', 'following', count, 'activitypub' ) }
+
+ ) ) }
+
+ ) }
+
+
+
+ );
+}
+
+/**
+ * Edit component.
+ *
+ * @param {Object} props Component props.
+ * @param {Object} props.attributes Block attributes.
+ * @param {Function} props.setAttributes Set block attributes.
+ * @param {Object} props.context Block context.
+ * @param {string} props.context.postType Post type.
+ * @param {number} props.context.postId Post ID.
+ * @return {JSX.Element} Edit component.
+ */
+export default function Edit( { attributes, setAttributes, context: { postType, postId } } ) {
+ const blockProps = useBlockProps( {
+ className: 'activitypub-follow-me-block-wrapper',
+ } );
+ const usersOptions = useUserOptions( { withInherit: true } );
+ const { namespace } = useOptions();
+ const { selectedUser, className = 'is-style-default' } = attributes;
+ const isInheritMode = selectedUser === 'inherit';
+ const [ profile, setProfile ] = useState( getNormalizedProfile( DEFAULT_PROFILE_DATA ) );
+ const userId = selectedUser === 'blog' ? 0 : selectedUser;
+
+ const TEMPLATE = [ [ 'core/button', { text: __( 'Follow', 'activitypub' ) } ] ];
+
+ const innerBlocksProps = useInnerBlocksProps(
+ {},
+ {
+ allowedBlocks: [ 'core/button' ],
+ template: TEMPLATE,
+ templateLock: false,
+ renderAppender: false,
+ }
+ );
+
+ const authorId = useSelect(
+ ( select ) => {
+ const { getEditedEntityRecord } = select( coreStore );
+ const _authorId = getEditedEntityRecord( 'postType', postType, postId )?.author;
+
+ return _authorId ?? null;
+ },
+ [ postType, postId ]
+ );
+
+ useEffect( () => {
+ // Fetch profile data when userId changes.
+ if ( isInheritMode && ! authorId ) {
+ return;
+ }
+
+ const effectiveUserId = isInheritMode ? authorId : userId;
+ fetchProfile( effectiveUserId )
+ .then( ( data ) => {
+ setProfile( getNormalizedProfile( data ) );
+
+ // Convert the full URL to a path if it's a local URL.
+ if ( data.followers ) {
+ try {
+ // Extract just the path portion from the URL
+ const { pathname: path } = new URL( data.followers );
+
+ apiFetch( { path: path.replace( 'wp-json/', '' ) } )
+ .then( ( { totalItems = 0 } ) => {
+ setProfile( ( prevProfile ) => ( { ...prevProfile, followersCount: totalItems } ) );
+ } )
+ .catch( () => {} );
+ } catch ( e ) {
+ // If URL parsing fails, just continue without fetching followers.
+ }
+ }
+
+ if ( effectiveUserId ) {
+ apiFetch( { path: `/wp/v2/users/${ effectiveUserId }/?context=activitypub` } )
+ .then( ( { post_count } ) => {
+ setProfile( ( prevProfile ) => ( { ...prevProfile, postsCount: post_count } ) );
+ } )
+ .catch( () => {} );
+ } else {
+ apiFetch( {
+ path: '/wp/v2/posts',
+ method: 'HEAD',
+ parse: false, // Preserve headers.
+ } )
+ .then( ( response ) => {
+ const postsCount = response.headers.get( 'X-WP-Total' );
+ setProfile( ( prevProfile ) => ( { ...prevProfile, postsCount } ) );
+ } )
+ .catch( () => {} );
+ }
+ } )
+ .catch( () => {} );
+ }, [ userId, authorId, isInheritMode ] );
+
+ useEffect( () => {
+ // If there are no users yet, do nothing.
+ if ( ! usersOptions.length ) {
+ return;
+ }
+ // Ensure that the selected user is in the list of options, if not, select the first available user.
+ if ( ! usersOptions.find( ( { value } ) => value === selectedUser ) ) {
+ setAttributes( { selectedUser: usersOptions[ 0 ].value } );
+ }
+ }, [ selectedUser, usersOptions ] );
+
+ return (
+
+
+ { usersOptions.length > 1 && (
+
+ setAttributes( { selectedUser: value } ) }
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ />
+
+ ) }
+
+
+ { isInheritMode && ! authorId ? (
+
+ ) : (
+
+ ) }
+
+ );
+}
diff --git a/src/blocks/follow-me/index.js b/src/blocks/follow-me/index.js
new file mode 100644
index 000000000..e41a5c698
--- /dev/null
+++ b/src/blocks/follow-me/index.js
@@ -0,0 +1,10 @@
+import { registerBlockType } from '@wordpress/blocks';
+import { people } from '@wordpress/icons';
+import deprecated from './deprecation';
+import edit from './edit';
+import metadata from './block.json';
+import save from './save';
+import './style.scss';
+
+// Register the block.
+registerBlockType( metadata, { deprecated, edit, icon: people, save } );
diff --git a/src/blocks/follow-me/render.php b/src/blocks/follow-me/render.php
new file mode 100644
index 000000000..df7f3ec4c
--- /dev/null
+++ b/src/blocks/follow-me/render.php
@@ -0,0 +1,244 @@
+ ACTIVITYPUB_REST_NAMESPACE,
+ 'i18n' => array(
+ 'copy' => __( 'Copy', 'activitypub' ),
+ 'copied' => __( 'Copied!', 'activitypub' ),
+ 'emptyProfileError' => __( 'Please enter a profile URL or handle.', 'activitypub' ),
+ 'genericError' => __( 'An error occurred. Please try again.', 'activitypub' ),
+ 'invalidProfileError' => __( 'Please enter a valid profile URL or handle.', 'activitypub' ),
+ ),
+ )
+);
+
+// Add the block wrapper attributes.
+$wrapper_attributes = array(
+ 'id' => $block_id,
+ 'class' => 'activitypub-follow-me-block-wrapper',
+ 'data-wp-interactive' => 'activitypub/follow-me',
+ 'data-wp-init' => 'callbacks.initButtonStyles',
+);
+if ( isset( $attributes['buttonOnly'] ) ) {
+ $wrapper_attributes['class'] .= ' is-style-button-only';
+}
+
+$wrapper_context = wp_interactivity_data_wp_context(
+ array(
+ 'backgroundColor' => $background_color,
+ 'blockId' => $block_id,
+ 'buttonStyle' => $button_style,
+ 'copyButtonText' => __( 'Copy', 'activitypub' ),
+ 'errorMessage' => '',
+ 'isError' => false,
+ 'isLoading' => false,
+ 'modal' => array( 'isOpen' => false ),
+ 'remoteProfile' => '',
+ 'userId' => $user_id,
+ 'webfinger' => '@' . $actor->get_webfinger(),
+ )
+);
+
+if ( empty( $content ) ) {
+ $button_text = $attributes['buttonText'] ?? __( 'Follow', 'activitypub' );
+ $content = '';
+} else {
+ $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
+}
+
+$content = Blocks::add_directions(
+ $content,
+ array( 'class_name' => 'wp-element-button' ),
+ array(
+ 'data-wp-on--click' => 'actions.toggleModal',
+ 'data-wp-on-async--keydown' => 'actions.onKeydown',
+ 'data-wp-bind--aria-expanded' => 'context.modal.isOpen',
+ 'aria-label' => __( 'Follow me on the Fediverse', 'activitypub' ),
+ 'aria-haspopup' => 'dialog',
+ 'aria-controls' => 'modal-heading',
+ 'role' => 'button',
+ 'tabindex' => '0',
+ )
+);
+
+$header_image = $actor->get_image();
+$has_header = ! empty( $header_image['url'] ) && str_contains( $attributes['className'] ?? '', 'is-style-profile' );
+
+$stats = array(
+ 'posts' => $user_id ? count_user_posts( $user_id, 'post', true ) : (int) wp_count_posts()->publish,
+ 'followers' => Followers::count_followers( $user_id ),
+);
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_summary() ) : ?>
+
+ get_summary() ); ?>
+
+
+
+
+
+
+ ' . esc_html( number_format_i18n( $stats['posts'] ) ) . ''
+ );
+ ?>
+
+
+
+
+ ' . esc_html( number_format_i18n( $stats['followers'] ) ) . ''
+ );
+ ?>
+
+
+
+
+
+
+
+ $modal_content,
+ /* translators: %s: Profile name. */
+ 'title' => sprintf( esc_html__( 'Follow %s', 'activitypub' ), esc_html( $actor->get_name() ) ),
+ )
+ );
+ ?>
+
diff --git a/src/blocks/follow-me/save.js b/src/blocks/follow-me/save.js
new file mode 100644
index 000000000..48c46b4aa
--- /dev/null
+++ b/src/blocks/follow-me/save.js
@@ -0,0 +1,17 @@
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+
+/**
+ * Save component for the Follow Me block.
+ *
+ * This component ensures that inner blocks (the button) are properly saved.
+ *
+ * @return {JSX.Element|null} Save component.
+ */
+function save() {
+ const blockProps = useBlockProps.save();
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps );
+
+ return
;
+}
+
+export default save;
diff --git a/src/blocks/follow-me/style.scss b/src/blocks/follow-me/style.scss
new file mode 100644
index 000000000..e8e997ed7
--- /dev/null
+++ b/src/blocks/follow-me/style.scss
@@ -0,0 +1,240 @@
+@import '../shared/modal/style';
+
+.activitypub-follow-me-block-wrapper {
+ display: block;
+ margin: 1rem 0;
+ position: relative;
+
+ .activitypub-profile {
+ padding: 1rem 0;
+
+ &__body {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ &__avatar {
+ width: 75px;
+ height: 75px;
+ border-radius: 50%;
+ margin-right: 1rem;
+ object-fit: cover;
+ }
+
+ &__content {
+ display: flex;
+ flex: 1;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ min-width: 0;
+ }
+
+ &__info {
+ display: block;
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__name {
+ font-size: 1.25em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__name,
+ &__handle {
+ color: inherit;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ div.wp-block-button { // Twenty Twenty Editor styles specificity.
+ margin: 0 0 0 1rem;
+ display: flex;
+ align-items: center;
+ }
+
+ .wp-block-button__link {
+ margin: 0;
+ }
+
+ .is-small {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.8rem;
+ }
+
+ .is-compact {
+ padding: 0.4rem 0.8rem;
+ font-size: 0.9rem;
+ }
+ }
+
+ // Style variations - applied to the wrapper
+ &:not(.is-style-button-only):not(.is-style-profile) {
+ .activitypub-profile__bio,
+ .activitypub-profile__stats {
+ display: none;
+ }
+ }
+
+ &.is-style-button-only {
+ .activitypub-profile {
+ padding: 0;
+ }
+
+ .activitypub-profile__body {
+ display: block;
+ padding: 0;
+ }
+
+ .activitypub-profile__content {
+ display: inline;
+ }
+
+ div.wp-block-button {
+ display: inline-block;
+ margin: 0;
+ }
+ .activitypub-profile__avatar,
+ .activitypub-profile__name,
+ .activitypub-profile__handle,
+ .activitypub-profile__bio,
+ .activitypub-profile__stats {
+ display: none;
+ }
+ }
+
+ &.is-style-profile {
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+
+ &.has-background {
+ .activitypub-profile {
+ padding: 0;
+ }
+ }
+
+ .activitypub-profile {
+ padding: 0;
+
+ &__header {
+ width: 100%;
+ height: 120px;
+ background-color: #ccc;
+ background-size: cover;
+ background-position: center;
+ }
+
+ &__body {
+ padding: 1rem;
+ }
+
+ &__avatar {
+ width: 64px;
+ height: 64px;
+ }
+
+ &__content {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__name {
+ margin-bottom: 0.25rem;
+ }
+
+ &__bio {
+ margin-top: 16px;
+ font-size: 90%;
+ line-height: 1.4;
+ width: 100%;
+
+ p {
+ margin: 0 0 0.5rem;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ &__stats {
+ display: flex;
+ gap: 16px;
+ margin-top: 1rem;
+ font-size: 0.9em;
+ width: 100%;
+ }
+ }
+ }
+
+ &.has-background .activitypub-profile,
+ &.has-border .activitypub-profile {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+}
+
+.activitypub-dialog {
+ &__section {
+ padding: 1.5rem 2rem;
+ border-bottom: 1px solid var(--wp--preset--color--light-gray, #f0f0f0);
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 2rem;
+ }
+
+ h4 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-size: 110%;
+ }
+ }
+
+ &__description {
+ margin-bottom: 1rem;
+ color: inherit;
+ font-size: 95%;
+ }
+
+ &__button-group {
+ display: flex;
+ width: 100%;
+ margin-bottom: 0.5rem;
+
+ input[type] {
+ flex: 1;
+ border: 1px solid var(--wp--preset--color--gray, #e2e4e7);
+ border-radius: 4px 0 0 4px;
+ line-height: 1;
+ margin: 0;
+
+ &::placeholder {
+ opacity: 0.5;
+ }
+
+ &[aria-invalid="true"] {
+ border-color: var(--wp--preset--color--vivid-red);
+ }
+ }
+
+ button {
+ border-radius: 0 4px 4px 0 !important;
+ margin-left: -1px !important;
+ min-width: 22.5%;
+ width: auto;
+ }
+ }
+
+ &__error {
+ color: var(--wp--preset--color--vivid-red);
+ font-size: 90%;
+ margin-top: 0.5rem;
+ }
+}
diff --git a/src/blocks/follow-me/view.js b/src/blocks/follow-me/view.js
new file mode 100644
index 000000000..92c1b96a1
--- /dev/null
+++ b/src/blocks/follow-me/view.js
@@ -0,0 +1,214 @@
+import { store, getContext, getElement } from '@wordpress/interactivity';
+import { getBlockStyles, getPopupStyles } from './button-style';
+import { createModalStore } from '../shared/modal';
+
+/** @var {object} wp WordPress global. */
+const { apiFetch } = window.wp;
+
+createModalStore( 'activitypub/follow-me' );
+
+/**
+ * @typedef {Object} state
+ * @property {String} namespace ActivityPub REST Namespace.
+ * @property {Object} i18n Internationalization strings.
+ * @property {String} i18n.copy "Copy" button text.
+ * @property {String} i18n.copied "Copied" button text.
+ * @property {String} i18n.emptyProfileError Error message for empty remote profile.
+ * @property {String} i18n.genericError Generic error message.
+ * @property {String} i18n.invalidProfileError Error message for invalid remote profile.
+ */
+
+/**
+ * @typedef {Object} context
+ * @property {String} backgroundColor The background color for the button.
+ * @property {String} blockId The block ID.
+ * @property {String} buttonStyle The button style.
+ * @property {String} copyButtonText The copy button text.
+ * @property {String} errorMessage The error message.
+ * @property {boolean} isError Whether the remote profile input has an error.
+ * @property {boolean} isLoading Whether the remote profile is being submitted.
+ * @property {Object} modal The modal state.
+ * @property {boolean} modal.isOpen Whether the modal is open.
+ * @property {String} template The template for the remote reply URL.
+ * @property {String} userId The user ID.
+ * @property {String} webfinger The webfinger of the user.
+ */
+
+const { actions, callbacks, state } = store( 'activitypub/follow-me', {
+ actions: {
+ /**
+ * Copy the webfinger to clipboard.
+ */
+ copyToClipboard() {
+ const context = getContext();
+
+ // Use the Clipboard API to copy text.
+ navigator.clipboard.writeText( context.webfinger ).then(
+ () => {
+ // Update button text to show success.
+ context.copyButtonText = state.i18n.copied;
+
+ // Reset button text after 1 second.
+ setTimeout( () => {
+ context.copyButtonText = state.i18n.copy;
+ }, 1000 );
+ },
+ ( error ) => {
+ // Log error if copying fails.
+ console.error( 'Could not copy text: ', error );
+ }
+ );
+ },
+
+ /**
+ * Update the remote profile value.
+ *
+ * @param {Event} event Input event.
+ */
+ updateRemoteProfile( event ) {
+ const context = getContext();
+ context.remoteProfile = event.target.value;
+ // Reset error state when input changes.
+ context.isError = false;
+ context.errorMessage = '';
+ },
+
+ /**
+ * Handle the opening of the modal.
+ *
+ * @param {Event} event The event that triggered the modal opening/closing.
+ * @param {String} event.key The key pressed, if any.
+ */
+ onKeydown( event ) {
+ if ( getElement().ref.tagName === 'A' && ( event.key === 'Enter' || event.key === ' ' ) ) {
+ event.preventDefault();
+ actions.toggleModal( event );
+ }
+ },
+
+ /**
+ * Handle keydown event for remote profile input.
+ *
+ * @param {Event} event Keydown event.
+ * @param {String} event.key The key pressed.
+ */
+ handleKeyDown( event ) {
+ if ( event.key === 'Enter' ) {
+ event.preventDefault();
+ actions.submitRemoteProfile();
+ }
+ },
+
+ /**
+ * Submit the remote profile.
+ */
+ submitRemoteProfile: function* () {
+ const context = getContext();
+ const { namespace } = state;
+ const input = context.remoteProfile.trim();
+
+ // Validate input.
+ if ( ! input ) {
+ context.isError = true;
+ context.errorMessage = state.i18n.emptyProfileError;
+ return;
+ }
+
+ if ( ! callbacks.isHandle( input ) ) {
+ context.isError = true;
+ context.errorMessage = state.i18n.invalidProfileError;
+ return;
+ }
+
+ // Set loading state.
+ context.isLoading = true;
+ context.isError = false;
+
+ // Construct the API path.
+ const path = `/${ namespace }/actors/${ context.userId }/remote-follow?resource=${ encodeURIComponent(
+ input
+ ) }`;
+
+ try {
+ // Make the API request.
+ const response = yield apiFetch( { path } );
+
+ // Set opening state.
+ context.isLoading = false;
+
+ // Open the remote follow URL in a new tab.
+ window.open( response.url, '_blank' );
+
+ // Close the modal after opening the URL.
+ actions.closeModal( new Event( 'click' ) );
+ } catch ( error ) {
+ // Handle error.
+ console.error( 'Error submitting profile:', error );
+ context.isLoading = false;
+ context.isError = true;
+ context.errorMessage = error.message || state.i18n.genericError;
+ }
+ },
+ },
+ callbacks: {
+ /**
+ * Initialize button styles.
+ */
+ initButtonStyles: () => {
+ const { buttonStyle, backgroundColor, blockId } = getContext();
+
+ // Add dynamic button styles to the document.
+ if ( blockId && buttonStyle ) {
+ const styleElement = document.createElement( 'style' );
+ const selector = `#${ blockId }`;
+
+ // Use getBlockStyles from button-style.js to get the CSS string.
+ styleElement.textContent = getBlockStyles( selector, buttonStyle, backgroundColor );
+
+ document.head.appendChild( styleElement );
+
+ // Add popup styles.
+ const popupStyleElement = document.createElement( 'style' );
+ popupStyleElement.textContent = getPopupStyles( buttonStyle );
+ document.head.appendChild( popupStyleElement );
+ }
+ },
+
+ /**
+ * Best guess whether a string is a valid ActivityPub handle.
+ *
+ * @param {string} string - String to check.
+ * @returns {boolean} True if string is a valid handle, false otherwise.
+ */
+ isHandle( string ) {
+ // Check if the string starts with '@' and contains a valid URL.
+ const parts = string.replace( /^@/, '' ).split( '@' );
+
+ return parts.length === 2 && callbacks.isUrl( `https://${ parts[ 1 ] }` );
+ },
+
+ /**
+ * Checks if a string is a valid URL.
+ *
+ * @param {string} string - String to check.
+ * @returns {boolean} True if string is a valid URL, false otherwise.
+ */
+ isUrl( string ) {
+ try {
+ new URL( string );
+ return true;
+ } catch ( _ ) {
+ return false;
+ }
+ },
+
+ /**
+ * Callback when modal is closed.
+ */
+ onModalClose() {
+ const context = getContext();
+
+ context.isError = false;
+ },
+ },
+} );
diff --git a/src/followers/block.json b/src/blocks/followers/block.json
similarity index 64%
rename from src/followers/block.json
rename to src/blocks/followers/block.json
index b058c01a7..a3c3f47b7 100644
--- a/src/followers/block.json
+++ b/src/blocks/followers/block.json
@@ -2,23 +2,20 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/followers",
"apiVersion": 3,
- "version": "1.0.0",
+ "version": "2.0.1",
"title": "Fediverse Followers",
"category": "widgets",
"description": "Display your followers from the Fediverse on your website.",
"textdomain": "activitypub",
"icon": "groups",
"supports": {
- "html": false
+ "html": false,
+ "interactivity": true
},
"attributes": {
- "title": {
- "type": "string",
- "default": "Fediverse Followers"
- },
"selectedUser": {
"type": "string",
- "default": "site"
+ "default": "blog"
},
"per_page": {
"type": "number",
@@ -32,11 +29,14 @@
},
"usesContext": [ "postType", "postId" ],
"styles": [
- { "name": "default", "label": "No Lines", "isDefault": true },
- { "name": "with-lines", "label": "Lines" },
+ { "name": "default", "label": "Default", "isDefault": true },
+ { "name": "card", "label": "Card" },
{ "name": "compact", "label": "Compact" }
],
"editorScript": "file:./index.js",
- "viewScript": "file:./view.js",
- "style": ["file:./style-view.css","wp-block-query-pagination"]
-}
\ No newline at end of file
+ "editorStyle": "file:./index.css",
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
+ "style": [ "file:./style-index.css" ],
+ "render": "file:./render.php"
+}
diff --git a/src/blocks/followers/deprecations.js b/src/blocks/followers/deprecations.js
new file mode 100644
index 000000000..5f341740f
--- /dev/null
+++ b/src/blocks/followers/deprecations.js
@@ -0,0 +1,58 @@
+import { createBlock } from '@wordpress/blocks';
+
+/**
+ * Deprecation for the followers block to handle the migration from custom title to InnerBlocks.
+ */
+const v1 = {
+ attributes: {
+ title: {
+ type: 'string',
+ default: 'Fediverse Followers',
+ },
+ selectedUser: {
+ type: 'string',
+ default: 'blog',
+ },
+ per_page: {
+ type: 'number',
+ default: 10,
+ },
+ order: {
+ type: 'string',
+ default: 'desc',
+ enum: [ 'asc', 'desc' ],
+ },
+ },
+ supports: {
+ html: false,
+ },
+
+ /**
+ * Checks if the block is eligible for migration.
+ *
+ * @param {Object} attributes The block attributes.
+ *
+ * @return {boolean} Whether the block is eligible for migration.
+ */
+ isEligible( { title } ) {
+ return !! title;
+ },
+
+ /**
+ * Migrates the block to use a core heading block instead of the custom heading attribute.
+ *
+ * @param {Object} attributes The attributes for the block.
+ *
+ * @return {[Object, Array]} An array with the new block attributes and inner blocks.
+ */
+ migrate: ( { title, ...newAttributes } ) => {
+ const headingBlock = createBlock( 'core/heading', {
+ content: title,
+ level: 3,
+ } );
+
+ return [ newAttributes, [ headingBlock ] ];
+ },
+};
+
+export default [ v1 ];
diff --git a/src/blocks/followers/edit.js b/src/blocks/followers/edit.js
new file mode 100644
index 000000000..2f995a33a
--- /dev/null
+++ b/src/blocks/followers/edit.js
@@ -0,0 +1,299 @@
+import apiFetch from '@wordpress/api-fetch';
+import { SelectControl, RangeControl, PanelBody } from '@wordpress/components';
+import { InspectorControls, useBlockProps, InnerBlocks } from '@wordpress/block-editor';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import { useState, useEffect } from '@wordpress/element';
+import { addQueryArgs } from '@wordpress/url';
+import { __ } from '@wordpress/i18n';
+import { useOptions } from '../shared/use-options';
+import { useUserOptions } from '../shared/use-user-options';
+import { InheritModeBlockFallback } from '../shared/inherit-block-fallback';
+
+/**
+ * Edit component.
+ *
+ * @param {Object} props Component props.
+ * @param {Object} props.attributes Block attributes.
+ * @param {Function} props.setAttributes Set block attributes.
+ * @param {Object} props.context Block context.
+ * @param {string} props.context.postType Post type.
+ * @param {number} props.context.postId Post ID.
+ *
+ * @return {JSX.Element} Edit component.
+ */
+export default function Edit( { attributes, setAttributes, context: { postType, postId } } ) {
+ const { className = '', order, per_page, selectedUser } = attributes;
+ const blockProps = useBlockProps();
+ const [ page, setPage ] = useState( 1 );
+ const orderOptions = [
+ { label: __( 'New to old', 'activitypub' ), value: 'desc' },
+ { label: __( 'Old to new', 'activitypub' ), value: 'asc' },
+ ];
+ const usersOptions = useUserOptions( { withInherit: true } );
+ const setAttributeWithPageReset = ( key ) => ( value ) => {
+ setPage( 1 );
+ setAttributes( { [ key ]: value } );
+ };
+ const authorId = useSelect(
+ ( select ) => {
+ const { getEditedEntityRecord } = select( coreStore );
+ const _authorId = getEditedEntityRecord( 'postType', postType, postId )?.author;
+
+ return _authorId ?? null;
+ },
+ [ postType, postId ]
+ );
+
+ useEffect( () => {
+ // if there are no users yet, do nothing
+ if ( ! usersOptions.length ) {
+ return;
+ }
+ // ensure that the selected user is in the list of options, if not, select the first available user
+ if ( ! usersOptions.find( ( { value } ) => value === selectedUser ) ) {
+ setAttributes( { selectedUser: usersOptions[ 0 ].value } );
+ }
+ }, [ selectedUser, usersOptions ] );
+
+ // Template for InnerBlocks - allows only a heading block.
+ const TEMPLATE = [
+ [
+ 'core/heading',
+ {
+ level: 3,
+ placeholder: __( 'Fediverse Followers', 'activitypub' ),
+ content: __( 'Fediverse Followers', 'activitypub' ),
+ },
+ ],
+ ];
+
+ return (
+
+
+
+ { usersOptions.length > 1 && (
+
+ ) }
+
+
+
+
+
+
+
+
+ { selectedUser === 'inherit' ? (
+ authorId ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ ) }
+
+
+ );
+}
+
+/**
+ * Builds the API path for fetching followers.
+ *
+ * @param {number} userId - The ID of the user whose followers are being fetched.
+ * @param {number} per_page - The number of followers to fetch per page.
+ * @param {string} order - The order in which to fetch followers ('asc' or 'desc').
+ * @param {number} page - The page number to fetch.
+ * @return {string} The API path with query arguments for fetching followers.
+ */
+function getPath( userId, per_page, order, page ) {
+ const { namespace } = useOptions();
+ const path = `/${ namespace }/actors/${ userId }/followers`;
+ const args = { per_page, order, page, context: 'full' };
+
+ return addQueryArgs( path, args );
+}
+
+/**
+ * Component to display followers of a user.
+ *
+ * @param {Object} props - The component props.
+ * @param {String} props.selectedUser - The ID of the user whose followers are being fetched.
+ * @param {number} props.per_page - The number of followers to fetch per page.
+ * @param {string} props.order - The order in which to fetch followers ('asc' or 'desc').
+ * @param {number} props.page - The page number to fetch.
+ * @param {function} props.setPage - The function to set the page number.
+ * @param {Object} props.followerData - Optional pre-fetched follower data.
+ */
+function Followers( {
+ selectedUser,
+ per_page,
+ order,
+ page: passedPage,
+ setPage: passedSetPage,
+ followerData = false,
+} ) {
+ const userId = selectedUser === 'blog' ? 0 : selectedUser;
+ const [ followers, setFollowers ] = useState( [] );
+ const [ pages, setPages ] = useState( 0 );
+ const [ total, setTotal ] = useState( 0 );
+ const [ localPage, setLocalPage ] = useState( 1 );
+ const page = passedPage || localPage;
+ const setPage = passedSetPage || setLocalPage;
+
+ const setData = ( followers, total ) => {
+ setFollowers( followers );
+ setTotal( total );
+ setPages( Math.ceil( total / per_page ) );
+ };
+
+ useEffect( () => {
+ if ( followerData && page === 1 ) {
+ return setData( followerData.followers, followerData.total );
+ }
+
+ const path = getPath( userId, per_page, order, page );
+ apiFetch( { path } )
+ .then( ( { orderedItems, totalItems } ) => setData( orderedItems, totalItems ) )
+ .catch( () => setData( [], 0 ) );
+ }, [ userId, per_page, order, page, followerData ] );
+
+ return (
+
+ { followers.length ? (
+
+ { followers.map( ( follower ) => (
+
+
+
+ ) ) }
+
+ ) : (
+
{ __( 'No followers found.', 'activitypub' ) }
+ ) }
+
+
+
+ );
+}
+
+/**
+ * Component to display pagination navigation.
+ *
+ * @param {Object} props - The component props.
+ * @param {number} props.page - The current page number.
+ * @param {number} props.pages - The total number of pages.
+ * @param {function} props.setPage - The function to set the page number.
+ */
+function Pagination( { page, pages, setPage } ) {
+ if ( pages <= 1 ) {
+ return null;
+ }
+
+ const disablePreviousLink = page <= 1;
+ const disableNextLink = page >= pages;
+
+ return (
+
+ { __( 'Follower navigation', 'activitypub' ) }
+ {
+ event.preventDefault();
+ setPage( page - 1 );
+ } }
+ >
+ { __( 'Previous', 'activitypub' ) }
+
+
+ { `${ page } / ${ pages }` }
+
+ {
+ event.preventDefault();
+ setPage( page + 1 );
+ } }
+ >
+ { __( 'Next', 'activitypub' ) }
+
+
+ );
+}
+
+/**
+ * Component to display a single follower.
+ *
+ * @param {Object} props - The component props.
+ * @param {string} props.name - The name of the follower.
+ * @param {Object} props.icon - The icon of the follower.
+ * @param {string} props.url - The URL of the follower.
+ * @param {string} props.preferredUsername - The preferred username of the follower.
+ */
+function Follower( { name, icon, url, preferredUsername } ) {
+ const handle = `@${ preferredUsername }`;
+ const { defaultAvatarUrl } = useOptions();
+ const avatar = icon.url || defaultAvatarUrl;
+
+ return (
+ event.preventDefault() }>
+ {
+ event.target.src = defaultAvatarUrl;
+ } }
+ />
+
+ { name }
+ { handle }
+
+
+
+
+
+ );
+}
diff --git a/src/blocks/followers/index.js b/src/blocks/followers/index.js
new file mode 100644
index 000000000..06c5c714b
--- /dev/null
+++ b/src/blocks/followers/index.js
@@ -0,0 +1,9 @@
+import { registerBlockType } from '@wordpress/blocks';
+import { people } from '@wordpress/icons';
+import deprecated from './deprecations';
+import edit from './edit';
+import metadata from './block.json';
+import save from './save';
+import './style.scss';
+
+registerBlockType( metadata, { deprecated, edit, save, icon: people } );
diff --git a/src/blocks/followers/render.php b/src/blocks/followers/render.php
new file mode 100644
index 000000000..f9493c048
--- /dev/null
+++ b/src/blocks/followers/render.php
@@ -0,0 +1,168 @@
+' . esc_html( $_title ) . '';
+ unset( $attributes['title'], $attributes['className'] );
+} else {
+ $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
+}
+
+$user_id = Blocks::get_user_id( $attributes['selectedUser'] );
+if ( is_null( $user_id ) ) {
+ return '';
+}
+
+$user = Actors::get_by_id( $user_id );
+if ( is_wp_error( $user ) ) {
+ return '';
+}
+
+$_per_page = absint( $attributes['per_page'] );
+$follower_data = Followers::get_followers_with_count( $user_id, $_per_page );
+
+// Prepare Followers data for the Interactivity API context.
+$followers = array_map(
+ /**
+ * Prepare follower data for the Interactivity API context.
+ *
+ * @param WP_Post $follower Follower object.
+ *
+ * @return array
+ */
+ function ( $follower ) {
+ $actor = Actors::get_actor( $follower );
+ $username = $actor->get_preferred_username();
+
+ return array(
+ 'handle' => '@' . $username,
+ 'icon' => $actor->get_icon(),
+ 'name' => $actor->get_name() ?? $username,
+ 'url' => object_to_uri( $actor->get_url() ) ?? $actor->get_id(),
+ );
+ },
+ $follower_data['followers']
+);
+
+// Set up the Interactivity API state.
+wp_interactivity_state(
+ 'activitypub/followers',
+ array(
+ 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
+ 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
+ )
+);
+
+// Set initial context data.
+$context = array(
+ 'followers' => $followers,
+ 'isLoading' => false,
+ 'order' => $attributes['order'],
+ 'page' => 1,
+ 'pages' => ceil( $follower_data['total'] / $_per_page ),
+ 'per_page' => $_per_page,
+ 'total' => $follower_data['total'],
+ 'userId' => $user_id,
+);
+
+// Get block wrapper attributes with the data-wp-interactive attribute.
+$wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'id' => wp_unique_id( 'activitypub-followers-block-' ),
+ 'data-wp-interactive' => 'activitypub/followers',
+ 'data-wp-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
+ )
+);
+?>
+
+>
+
+
+
+
+
+ $_per_page ) : ?>
+
+
+
+
+
+
diff --git a/src/blocks/followers/save.js b/src/blocks/followers/save.js
new file mode 100644
index 000000000..e63c77c78
--- /dev/null
+++ b/src/blocks/followers/save.js
@@ -0,0 +1,13 @@
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+
+/**
+ * Save function for the Followers block.
+ *
+ * @return {JSX.Element} Element to render.
+ */
+export default function save() {
+ const blockProps = useBlockProps.save();
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps );
+
+ return
;
+}
diff --git a/src/blocks/followers/style.scss b/src/blocks/followers/style.scss
new file mode 100644
index 000000000..19e5af8f8
--- /dev/null
+++ b/src/blocks/followers/style.scss
@@ -0,0 +1,290 @@
+button {
+ border: none;
+}
+
+.wp-block-activitypub-followers {
+ margin: 16px 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+
+ // Block title.
+ .wp-block-heading {
+ margin: 0 0 16px;
+ padding: 0 0 8px;
+ border-bottom: 1px solid;
+ }
+
+ .wp-block-heading,
+ .followers-pagination {
+ border-color: var(--wp--preset--color--foreground,
+ var(--wp--preset--color--primary,
+ #e0e0e0
+ )
+ );
+ }
+
+ .followers-container {
+ position: relative;
+
+ .followers-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .follower-item {
+ margin: 0 0 8px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .follower-link {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border: none; // Twenty Fifteen.
+ border-radius: 8px;
+ box-shadow: none; // Twenty Sixteen.
+ transition: background-color 0.2s ease;
+
+ &:hover, &:focus {
+ background-color: var(--wp--preset--color--subtle-background,
+ var(--wp--preset--color--accent-2,
+ var(--wp--preset--color--tertiary,
+ var(--wp--preset--color--secondary,
+ #f0f0f0
+ )
+ )
+ )
+ );
+ box-shadow: none; // Twenty Seventeen.
+ outline: none;
+
+ .external-link-icon {
+ opacity: 1;
+ }
+ }
+ }
+
+ .follower-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-right: 16px;
+ border: 1px solid #e0e0e0;
+ }
+
+ .follower-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ line-height: 1.3;
+ }
+
+ .follower-name {
+ font-weight: 600;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .follower-username {
+ color: var(--wp--preset--color--very-dark-gray, #666);
+ font-size: 90%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .external-link-icon {
+ width: 16px;
+ height: 16px;
+ margin-left: 8px;
+ transition: opacity 0.2s ease;
+ }
+
+ .followers-pagination {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ margin-top: 16px;
+ padding-top: 8px !important; // Twenty Eleven.
+ border-top-style: solid;
+ border-top-width: 1px;
+
+ .pagination-info {
+ color: var(--wp--preset--color--very-dark-gray, #666);
+ font-size: 90%;
+ justify-self: center;
+ }
+
+ .pagination-previous,
+ .pagination-next {
+ border: none; // Twenty Fifteen.
+ box-shadow: none; // Twenty Sixteen.
+ cursor: pointer;
+ font-size: 90%;
+ display: inline-block;
+ padding: 8px 0;
+ min-width: 60px;
+
+ &[hidden] {
+ display: none !important;
+ }
+
+ &[aria-disabled="true"] {
+ opacity: 0.3;
+ cursor: not-allowed;
+ pointer-events: none;
+ text-decoration: none;
+ }
+ }
+
+ .pagination-previous {
+ justify-self: start;
+ padding-right: 8px;
+
+ &::before {
+ content: "←";
+ }
+ }
+
+ .pagination-next {
+ justify-self: end;
+ padding-left: 8px;
+ text-align: right;
+
+ &::after {
+ content: "→";
+ }
+ }
+
+ @media (max-width: 480px) {
+ grid-template-columns: 1fr 1fr;
+
+ .pagination-info {
+ display: none;
+ }
+
+ .pagination-previous,
+ .pagination-next {
+ min-height: 44px;
+ font-size: 100%;
+ align-items: center;
+ }
+ }
+ }
+
+ // Loading spinner
+ .followers-loading {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(255, 255, 255, 0.5);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &[aria-hidden="true"] {
+ display: none;
+ }
+ }
+
+ .loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid color-mix(in srgb, var(--wp--preset--color--primary, #0073aa) 30%, transparent);
+ border-radius: 50%;
+ border-top-color: var(--wp--preset--color--primary, #0073aa);
+ animation: spin 1s ease-in-out infinite;
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+ }
+}
+
+// Card style variation
+.wp-block-activitypub-followers.is-style-card:not(.block-editor-block-list__block) {
+ background-color: var(--wp--preset--color--white, #fff);
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ box-sizing: border-box;
+ padding: 24px;
+
+ @media (max-width: 480px) {
+ margin-left: -12px;
+ margin-right: -12px;
+ }
+
+ .wp-block-heading {
+ text-align: center;
+ border-bottom: none;
+ margin-bottom: 16px;
+ }
+
+ .follower-link {
+ border: 1px solid #e0e0e0;
+ margin-bottom: 8px;
+
+ &:hover, &:focus {
+ border-color: darken(#e0e0e0, 10%);
+ }
+ }
+
+ .followers-pagination {
+ border: none;
+ padding-bottom: 0 !important; // Twenty Eleven.
+ }
+}
+
+// Compact style variation
+.wp-block-activitypub-followers.is-style-compact {
+ .follower-link {
+ padding: 4px;
+ }
+
+ .follower-avatar {
+ width: 36px;
+ height: 36px;
+ margin-right: 8px;
+ }
+
+ .follower-name {
+ font-size: 90%;
+ }
+
+ .follower-username {
+ font-size: 80%;
+ }
+
+ .followers-pagination {
+ margin-top: 8px;
+ padding-top: 4px;
+
+ .pagination-previous,
+ .pagination-next {
+ padding-top: 4px;
+ padding-bottom: 4px;
+ font-size: 80%;
+
+ @media (max-width: 480px) {
+ font-size: 100%;
+ }
+ }
+
+ .pagination-info {
+ font-size: 80%;
+ }
+ }
+}
diff --git a/src/blocks/followers/view.js b/src/blocks/followers/view.js
new file mode 100644
index 000000000..2b6c93d87
--- /dev/null
+++ b/src/blocks/followers/view.js
@@ -0,0 +1,148 @@
+import { store, getContext } from '@wordpress/interactivity';
+
+/**
+ * @var {Object} window.wp WordPress global object
+ * @var {Function} url.addQueryArgs Function to add query arguments to a URL.
+ */
+const { apiFetch, url } = window.wp;
+
+/**
+ * @typedef {Object} context
+ * @property {Array} followers The list of followers.
+ * @property {boolean} isLoading Whether the followers are currently being fetched.
+ * @property {String} order The order in which to fetch followers (e.g., 'asc', 'desc').
+ * @property {Number} page The current page of followers.
+ * @property {Number} pages The total number of pages of followers.
+ * @property {Number} per_page The number of followers per page.
+ * @property {Number} total The total number of followers.
+ * @property {String} userId The user ID for which to fetch followers.
+ */
+
+const { actions, state } = store( 'activitypub/followers', {
+ /**
+ * @typedef {Object} state
+ * @property {String} defaultAvatarUrl Default avatar URL.
+ * @property {String} namespace ActivityPub REST Namespace.
+ * @property {Function} paginationText Get the pagination text.
+ * @property {Function} disablePreviousLink Whether the previous link should be disabled.
+ * @property {Function} disableNextLink Whether the next link should be disabled.
+ */
+ state: {
+ /**
+ * Get the pagination text.
+ *
+ * @returns {string}
+ */
+ get paginationText() {
+ const { page, pages } = getContext();
+ return `${ page } / ${ pages }`;
+ },
+
+ /**
+ * Check if the previous link should be disabled.
+ *
+ * @returns {boolean}
+ */
+ get disablePreviousLink() {
+ const { page } = getContext();
+ return page <= 1;
+ },
+
+ /**
+ * Check if the next link should be disabled.
+ *
+ * @returns {boolean}
+ */
+ get disableNextLink() {
+ const { page, pages } = getContext();
+ return page >= pages;
+ },
+ },
+ actions: {
+ /**
+ * Fetch followers for the current page.
+ *
+ * @return {Promise} Promise that resolves when followers are fetched.
+ */
+ async fetchFollowers() {
+ const context = getContext();
+ const { userId, page, per_page, order } = context;
+
+ // Set loading state.
+ context.isLoading = true;
+
+ try {
+ // Build the API path and parameters
+ const path = url.addQueryArgs( `/${ state.namespace }/actors/${ userId }/followers`, {
+ context: 'full',
+ per_page,
+ order,
+ page,
+ } );
+
+ // Use apiFetch to get the Followers data.
+ const { orderedItems, totalItems } = await apiFetch( { path } );
+
+ // Update the context with the new followers.
+ context.followers = orderedItems.map( ( follower ) => ( {
+ handle: '@' + follower.preferredUsername,
+ icon: follower.icon,
+ name: follower.name || follower.preferredUsername,
+ url: follower.url || follower.id,
+ } ) );
+
+ context.total = totalItems;
+ context.pages = Math.ceil( totalItems / per_page );
+ } catch ( error ) {
+ console.error( 'Error fetching followers:', error );
+ } finally {
+ // Clear loading state.
+ context.isLoading = false;
+ }
+ },
+
+ /**
+ * Navigate to the previous page.
+ *
+ * @param {Event} event - The click event.
+ */
+ previousPage( event ) {
+ event.preventDefault();
+ const context = getContext();
+
+ if ( context.page > 1 ) {
+ context.page--;
+ actions.fetchFollowers().catch( ( error ) => {
+ console.error( 'Error fetching followers:', error );
+ } );
+ }
+ },
+
+ /**
+ * Navigate to the next page.
+ *
+ * @param {Event} event - The click event.
+ */
+ nextPage( event ) {
+ event.preventDefault();
+ const context = getContext();
+
+ if ( context.page < context.pages ) {
+ context.page++;
+ actions.fetchFollowers().catch( ( error ) => {
+ console.error( 'Error fetching followers:', error );
+ } );
+ }
+ },
+ },
+ callbacks: {
+ /**
+ * Sets the default avatar when the avatar image fails to load.
+ *
+ * @param {Object} event The error event.
+ */
+ setDefaultAvatar( event ) {
+ event.target.src = state.defaultAvatarUrl;
+ },
+ },
+} );
diff --git a/src/blocks/reactions/block.json b/src/blocks/reactions/block.json
new file mode 100644
index 000000000..cd3130831
--- /dev/null
+++ b/src/blocks/reactions/block.json
@@ -0,0 +1,48 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "name": "activitypub/reactions",
+ "apiVersion": 3,
+ "version": "3.0.3",
+ "title": "Fediverse Reactions",
+ "category": "widgets",
+ "icon": "heart",
+ "description": "Display Fediverse likes and reposts",
+ "supports": {
+ "align": [ "wide", "full" ],
+ "color": {
+ "gradients": true
+ },
+ "__experimentalBorder": {
+ "radius": true,
+ "width": true,
+ "color": true,
+ "style": true
+ },
+ "html": false,
+ "interactivity": true,
+ "layout": {
+ "default": {
+ "type": "constrained",
+ "orientation": "vertical",
+ "justifyContent": "center"
+ },
+ "allowEditing": false
+ },
+ "shadow": true,
+ "typography": {
+ "fontSize": true,
+ "__experimentalDefaultControls": {
+ "fontSize": true
+ }
+ }
+ },
+ "blockHooks": {
+ "core/post-content": "after"
+ },
+ "textdomain": "activitypub",
+ "editorScript": "file:./index.js",
+ "style": "file:./style-index.css",
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
+ "render": "file:./render.php"
+}
diff --git a/src/blocks/reactions/deprecation.js b/src/blocks/reactions/deprecation.js
new file mode 100644
index 000000000..5584bd24a
--- /dev/null
+++ b/src/blocks/reactions/deprecation.js
@@ -0,0 +1,64 @@
+import { createBlock } from '@wordpress/blocks';
+
+const v1 = {
+ attributes: {
+ title: {
+ type: 'string',
+ default: 'Fediverse reactions',
+ },
+ },
+
+ supports: {
+ html: false,
+ color: {
+ gradients: true,
+ link: true,
+ __experimentalDefaultControls: {
+ background: true,
+ text: true,
+ link: true,
+ },
+ },
+ __experimentalBorder: {
+ radius: true,
+ width: true,
+ color: true,
+ style: true,
+ },
+ typography: {
+ fontSize: true,
+ __experimentalDefaultControls: {
+ fontSize: true,
+ },
+ },
+ },
+
+ /**
+ * Checks if the block is eligible for migration.
+ *
+ * @param {Object} attributes The block attributes.
+ *
+ * @return {boolean} Whether the block is eligible for migration.
+ */
+ isEligible( { title } ) {
+ return !! title;
+ },
+
+ /**
+ * Migrates the block to use a core heading block instead of the custom heading attribute.
+ *
+ * @param {Object} attributes The attributes for the block.
+ *
+ * @return {Array} The new attributes and inner blocks.
+ */
+ migrate( { title, ...newAttributes } ) {
+ const headingBlock = createBlock( 'core/heading', {
+ content: title,
+ level: 6,
+ } );
+
+ return [ newAttributes, [ headingBlock ] ];
+ },
+};
+
+export default [ v1 ];
diff --git a/src/blocks/reactions/edit.js b/src/blocks/reactions/edit.js
new file mode 100644
index 000000000..c2a9b7974
--- /dev/null
+++ b/src/blocks/reactions/edit.js
@@ -0,0 +1,77 @@
+import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
+import { __, _x, sprintf } from '@wordpress/i18n';
+import { select } from '@wordpress/data';
+import { Reactions } from './reactions';
+
+// Generate reaction items with SVG avatars.
+const generateReactionItems = ( count, prefix, startChar, colors ) =>
+ Array.from( { length: count }, ( _, i ) => ( {
+ name: `${ prefix } ${ i + 1 }`,
+ url: '#',
+ avatar: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='32' fill='%23${
+ colors[ i % colors.length ]
+ }'/%3E%3Ctext x='32' y='38' font-family='sans-serif' font-size='24' fill='white' text-anchor='middle'%3E${ String.fromCharCode(
+ startChar + i
+ ) }%3C/text%3E%3C/svg%3E`,
+ } ) );
+
+// Colors for avatars.
+const COLORS = [ 'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'D4A5A5', '9B59B6', '3498DB', 'E67E22' ];
+
+// Simple predefined dummy Reactions data.
+const DUMMY_REACTIONS = {
+ likes: {
+ label: sprintf(
+ /* translators: %d: Number of likes */
+ _x( '%d likes', 'number of likes', 'activitypub' ),
+ 9
+ ),
+ items: generateReactionItems( 9, 'User', 65, COLORS ), // 65 is ASCII for 'A'
+ },
+ reposts: {
+ label: sprintf(
+ /* translators: %d: Number of reposts */
+ _x( '%d reposts', 'number of reposts', 'activitypub' ),
+ 6
+ ),
+ items: generateReactionItems( 6, 'Reposter', 82, COLORS ), // 82 is ASCII for 'R'
+ },
+};
+
+/**
+ * Edit component for the Reactions block.
+ *
+ * @param {Object} props Block props.
+ * @param props.__unstableLayoutClassNames Layout class names.
+ * @return {JSX.Element} Component to render.
+ */
+export default function Edit( { __unstableLayoutClassNames } ) {
+ const blockProps = useBlockProps( {
+ className: __unstableLayoutClassNames,
+ } );
+ const { getCurrentPostId } = select( 'core/editor' );
+
+ // Template for InnerBlocks - allows only a heading block.
+ const TEMPLATE = [
+ [
+ 'core/heading',
+ {
+ level: 6,
+ placeholder: __( 'Fediverse Reactions', 'activitypub' ),
+ content: __( 'Fediverse Reactions', 'activitypub' ),
+ },
+ ],
+ ];
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/reactions/index.js b/src/blocks/reactions/index.js
similarity index 75%
rename from src/reactions/index.js
rename to src/blocks/reactions/index.js
index 5e8f849de..4d4edde56 100644
--- a/src/reactions/index.js
+++ b/src/blocks/reactions/index.js
@@ -6,8 +6,4 @@ import metadata from './block.json';
import save from './save';
import './style.scss';
-registerBlockType( metadata.name, {
- deprecated,
- edit,
- save,
-} );
+registerBlockType( metadata, { deprecated, edit, save } );
diff --git a/src/blocks/reactions/reactions.js b/src/blocks/reactions/reactions.js
new file mode 100644
index 000000000..1de8cf8a5
--- /dev/null
+++ b/src/blocks/reactions/reactions.js
@@ -0,0 +1,192 @@
+import { useState, useEffect, useRef } from '@wordpress/element';
+import { Popover, Button } from '@wordpress/components';
+import apiFetch from '@wordpress/api-fetch';
+import { useOptions } from '../shared/use-options';
+
+/**
+ * @typedef {Object} JSX
+ * @typedef {import('react').ReactElement} JSX.Element
+ */
+
+/**
+ * A component that renders a row of user avatars for a given set of reactions.
+ *
+ * @param {Object} props Component props.
+ * @param {Array} props.reactions Array of reaction objects.
+ * @return {JSX.Element} The rendered component.
+ */
+const FacepileRow = ( { reactions } ) => {
+ const { defaultAvatarUrl } = useOptions();
+
+ return (
+
+ );
+};
+
+/**
+ * A component that renders a dropdown list of reactions.
+ *
+ * @param {Object} props Component props.
+ * @param {Array} props.reactions Array of reaction objects.
+ * @return {JSX.Element} The rendered component.
+ */
+const ReactionList = ( { reactions } ) => {
+ const { defaultAvatarUrl } = useOptions();
+
+ return (
+
+ );
+};
+
+/**
+ * A component that renders a reaction group with facepile and dropdown.
+ *
+ * @param {Object} props Component props.
+ * @param {Array} props.items Array of reaction objects.
+ * @param {string} props.label Label for the reaction group.
+ * @return {JSX.Element} The rendered component.
+ */
+const ReactionGroup = ( { items, label } ) => {
+ const [ isOpen, setIsOpen ] = useState( false );
+ const [ buttonRef, setButtonRef ] = useState( null );
+ const containerRef = useRef( null );
+
+ const visibleItems = items.slice( 0, 20 );
+
+ return (
+
+
+
setIsOpen( ! isOpen ) }
+ aria-expanded={ isOpen }
+ >
+ { label }
+
+ { isOpen && buttonRef && (
+
setIsOpen( false ) }>
+
+
+ ) }
+
+ );
+};
+
+/**
+ * The Reactions component.
+ *
+ * @param {Object} props Component props.
+ * @param {?number} props.postId The Post ID.
+ * @param {?Object} props.reactions Optional reactions data.
+ * @param {?Object} props.fallbackReactions Optional fallback reactions data to use if no real reactions are found.
+ * @return {?JSX.Element} The rendered component.
+ */
+export function Reactions( { postId = null, reactions: providedReactions = null, fallbackReactions = null } ) {
+ const { namespace } = useOptions();
+ const [ reactions, setReactions ] = useState( providedReactions );
+ const [ loading, setLoading ] = useState( ! providedReactions );
+
+ const onError = () => {
+ // On error, use fallback reactions if provided
+ if ( fallbackReactions ) {
+ setReactions( fallbackReactions );
+ }
+ setLoading( false );
+ };
+
+ useEffect( () => {
+ if ( providedReactions ) {
+ setReactions( providedReactions );
+ setLoading( false );
+ return;
+ }
+
+ // if no postId is provided or it's not a number (Site Editor), return early.
+ if ( ! postId || typeof postId !== 'number' ) {
+ onError();
+ return;
+ }
+
+ setLoading( true );
+ apiFetch( {
+ path: `/${ namespace }/posts/${ postId }/reactions`,
+ } )
+ .then( ( response ) => {
+ // Check if the response has any actual reactions
+ const hasReactions = Object.values( response ).some( ( group ) => group.items?.length > 0 );
+
+ // If there are no real reactions and fallback is provided, use the fallback.
+ if ( ! hasReactions && fallbackReactions ) {
+ setReactions( fallbackReactions );
+ } else {
+ setReactions( response );
+ }
+ setLoading( false );
+ } )
+ .catch( onError );
+ }, [ postId, providedReactions, fallbackReactions, namespace ] );
+
+ if ( loading ) {
+ return null;
+ }
+
+ // Return null if there are no reactions
+ if ( ! reactions || ! Object.values( reactions ).some( ( group ) => group.items?.length > 0 ) ) {
+ return null;
+ }
+
+ return (
+ <>
+ { Object.entries( reactions ).map( ( [ key, group ] ) => {
+ if ( ! group.items?.length ) {
+ return null;
+ }
+
+ return ;
+ } ) }
+ >
+ );
+}
diff --git a/src/blocks/reactions/render.php b/src/blocks/reactions/render.php
new file mode 100644
index 000000000..67992c7e0
--- /dev/null
+++ b/src/blocks/reactions/render.php
@@ -0,0 +1,218 @@
+ null ) );
+
+/* @var \WP_Block $block Current block. */
+$block = $block ?? '';
+
+/* @var string $content Block content. */
+$content = $content ?? '';
+
+if ( empty( $content ) ) {
+ // Fallback for v1.0.0 blocks.
+ $_title = $attributes['title'] ?? __( 'Fediverse Reactions', 'activitypub' );
+ $content = '' . esc_html( $_title ) . ' ';
+ unset( $attributes['title'], $attributes['className'] );
+} else {
+ $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
+}
+
+// Get the Post ID from attributes or use the current post.
+$_post_id = $attributes['postId'] ?? get_the_ID();
+
+// Generate a unique ID for the block.
+$block_id = 'activitypub-reactions-block-' . wp_unique_id();
+
+$reactions = array();
+
+foreach ( Comment::get_comment_types() as $_type => $type_object ) {
+ $_comments = get_comments(
+ array(
+ 'post_id' => $_post_id,
+ 'type' => $_type,
+ 'status' => 'approve',
+ 'parent' => 0,
+ )
+ );
+
+ if ( empty( $_comments ) ) {
+ continue;
+ }
+
+ $count = count( $_comments );
+ // phpcs:disable WordPress.WP.I18n
+ $label = sprintf(
+ _n(
+ $type_object['count_single'],
+ $type_object['count_plural'],
+ $count,
+ 'activitypub'
+ ),
+ number_format_i18n( $count )
+ );
+ // phpcs:enable WordPress.WP.I18n
+
+ $reactions[ $_type ] = array(
+ 'label' => $label,
+ 'count' => $count,
+ 'items' => array_map(
+ function ( $comment ) {
+ return array(
+ 'name' => html_entity_decode( $comment->comment_author ),
+ 'url' => $comment->comment_author_url,
+ 'avatar' => get_avatar_url( $comment ),
+ );
+ },
+ $_comments
+ ),
+ );
+}
+
+if ( empty( $reactions ) ) {
+ echo '';
+ return;
+}
+
+// Set up the Interactivity API state.
+wp_interactivity_state(
+ 'activitypub/reactions',
+ array(
+ 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
+ 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
+ 'reactions' => array(
+ $_post_id => $reactions,
+ ),
+ )
+);
+
+// Render a subset of the most recent reactions.
+$reactions = array_map(
+ function ( $reaction ) use ( $attributes ) {
+ $count = 20;
+ if ( 'wide' === $attributes['align'] ) {
+ $count = 40;
+ } elseif ( 'full' === $attributes['align'] ) {
+ $count = 60;
+ }
+
+ $reaction['items'] = array_slice( array_reverse( $reaction['items'] ), 0, $count );
+
+ return $reaction;
+ },
+ $reactions
+);
+
+// Initialize the context for the block.
+$context = array(
+ 'blockId' => $block_id,
+ 'modal' => array(
+ 'isCompact' => true,
+ 'isOpen' => false,
+ 'items' => array(),
+ ),
+ 'postId' => $_post_id,
+ 'reactions' => $reactions,
+);
+
+// Add the block wrapper attributes.
+$wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'id' => $block_id,
+ 'data-wp-interactive' => 'activitypub/reactions',
+ 'data-wp-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
+ 'data-wp-init' => 'callbacks.initReactions',
+ )
+);
+
+ob_start();
+?>
+
+
+
+>
+
+
+
+ $reaction ) :
+ /* translators: %s: reaction type. */
+ $aria_label = sprintf( __( 'View all %s', 'activitypub' ), Comment::get_comment_type_attr( $_type, 'label' ) );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true,
+ 'content' => $modal_content,
+ )
+ );
+ ?>
+
diff --git a/src/blocks/reactions/save.js b/src/blocks/reactions/save.js
new file mode 100644
index 000000000..3fb25192e
--- /dev/null
+++ b/src/blocks/reactions/save.js
@@ -0,0 +1,22 @@
+import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * @typedef {Object} InnerBlocks
+ * @property {function(): JSX.Element} Content - The InnerBlocks.Content component.
+ */
+
+/**
+ * Save function for the reactions block.
+ *
+ * With server-side rendering via render.php, we only need to output
+ * the InnerBlocks content and a placeholder div.
+ *
+ * @return {JSX.Element} React element to save.
+ */
+export default function save() {
+ return (
+
+
+
+ );
+}
diff --git a/src/blocks/reactions/style.scss b/src/blocks/reactions/style.scss
new file mode 100644
index 000000000..2cc3f0c4c
--- /dev/null
+++ b/src/blocks/reactions/style.scss
@@ -0,0 +1,176 @@
+@import '../shared/modal/style';
+
+.wp-block-activitypub-reactions {
+ margin-top: 2rem;
+ margin-bottom: 2rem;
+ position: relative;
+
+ &.has-background,
+ &.has-border {
+ box-sizing: border-box;
+ padding: 2rem;
+ }
+
+ // Main container for reactions.
+ .activitypub-reactions {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ }
+
+ // Reaction group for each type (likes, reposts).
+ .reaction-group {
+ display: flex;
+ align-items: center;
+ margin: 0.5em 0;
+ position: relative;
+ width: 100%;
+ gap: 0.75rem;
+ justify-content: flex-start;
+
+ // When content overflows, switch to space-between.
+ &:has(.reaction-avatars:not(:empty)) {
+ @media (max-width: 782px) {
+ justify-content: space-between;
+ }
+ }
+
+ // Container for avatar images.
+ .reaction-avatars {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ list-style: none;
+ margin: 0 !important;
+ padding: 0;
+
+ li {
+ padding: 0;
+ margin: 0 -10px 0 0;
+ transition: transform 0.2s ease;
+
+ // Remove margin on avatar that doesn't have a hidden sibling.
+ &:not([hidden]):not(:has(~ li:not([hidden]))) {
+ margin-right: 0;
+ }
+
+ &:hover {
+ z-index: 2;
+ transform: translateY(-2px);
+ }
+
+ a {
+ box-shadow: none;
+ border-radius: 50%;
+ display: block;
+ line-height: 1;
+ text-decoration: none;
+ }
+ }
+ }
+
+ .reaction-avatar {
+ max-width: 32px;
+ max-height: 32px;
+ overflow: hidden;
+ -moz-force-broken-image-icon: 1;
+ border-radius: 50%;
+ border: 0.5px solid var(--wp--preset--color--contrast, rgba(255, 255, 255, 0.8));
+ box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.8), // Crisp white border
+ 0 1px 3px rgba(0, 0, 0, 0.2); // Soft drop shadow
+ transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
+ will-change: transform;
+
+ &:hover,
+ &:focus-visible {
+ z-index: 1;
+ position: relative;
+ transform: translateY(-5px);
+ }
+ }
+
+ // Label showing count of reactions.
+ .reaction-label {
+ background: none;
+ border: none;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex: 0 0 auto;
+ padding: 0.25rem 0.5rem;
+ font-size: 70%;
+ color: currentColor;
+ transition: background-color 0.2s ease;
+ white-space: nowrap;
+ text-decoration: none;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+ color: currentColor;
+ }
+
+ &:focus:not(:disabled) {
+ box-shadow: none;
+ outline: 1px solid currentColor;
+ outline-offset: 2px;
+ }
+ }
+ }
+}
+
+/* Reactions list styles */
+.reactions-list {
+ list-style: none;
+ margin: 0 !important;
+ padding: 0.5rem;
+
+ .components-popover__content > & {
+ padding: 0;
+ }
+
+ .reaction-item {
+ margin: 0 0 0.5rem 0;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ a {
+ box-shadow: none;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ text-decoration: none;
+ color: inherit;
+ transition: background-color 0.2s ease;
+ padding: 0.5rem;
+ border-radius: 4px;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.03);
+ }
+ }
+
+ img {
+ box-shadow: none;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 1px solid var(--wp--preset--color--light-gray, #f0f0f0);
+ }
+
+ .reaction-name {
+ font-size: 75%;
+ }
+ }
+}
+
+.components-popover__content {
+ width: auto;
+ max-width: min-content;
+ min-width: 250px;
+ max-height: 300px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ padding: 0.5rem;
+}
diff --git a/src/blocks/reactions/view.js b/src/blocks/reactions/view.js
new file mode 100644
index 000000000..797aa35a9
--- /dev/null
+++ b/src/blocks/reactions/view.js
@@ -0,0 +1,146 @@
+import { getContext, getElement, store, withScope } from '@wordpress/interactivity';
+import { createModalStore } from '../shared/modal';
+
+/** @var {Object} window.wp WordPress global object */
+const { apiFetch } = window.wp;
+
+createModalStore( 'activitypub/reactions' );
+
+/**
+ * @typedef {Object} state
+ * @property {Object} reactions Reactions data, keyed by post ID.
+ * @property {String} defaultAvatarUrl Default avatar URL.
+ * @property {String} namespace API namespace for ActivityPub.
+ */
+
+/**
+ * @typedef {Object} context
+ * @property {String} blockId The block ID.
+ * @property {Object} modal The modal state.
+ * @property {boolean} modal.isCompact Whether the modal is compact.
+ * @property {boolean} modal.isOpen Whether the modal is open.
+ * @property {Object} modal.items The items to display in the modal.
+ * @property {String} postId The post ID.
+ * @property {Object} reactions Reactions data, keyed by reaction type.
+ */
+
+const { callbacks, state } = store( 'activitypub/reactions', {
+ actions: {
+ /**
+ * Fetches reactions for a post.
+ */
+ async fetchReactions() {
+ const context = getContext();
+ const { namespace } = state;
+
+ if ( ! context.postId ) return;
+
+ try {
+ // Update the state with the new Reactions data.
+ context.reactions = await apiFetch( {
+ path: `/${ namespace }/posts/${ context.postId }/reactions`,
+ } );
+ } catch ( error ) {
+ console.error( 'Error fetching reactions:', error );
+ }
+ },
+ },
+ callbacks: {
+ /**
+ * Initializes the Reactions component.
+ */
+ initReactions() {
+ // Set up resize observer to recalculate on window resize.
+ const resizeObserver = new ResizeObserver( withScope( callbacks.calculateVisibleAvatars ) );
+ getElement()
+ .ref.querySelectorAll( '.reaction-group' )
+ .forEach( ( group ) => {
+ resizeObserver.observe( group );
+ } );
+
+ // Return a cleanup function to disconnect the observer when the block is unmounted.
+ return () => {
+ resizeObserver.disconnect();
+ };
+ },
+
+ /**
+ * Calculates and sets the number of visible avatars based on container width.
+ */
+ calculateVisibleAvatars() {
+ const { postId } = getContext();
+
+ // Constants for calculations
+ const AVATAR_WIDTH = 32; // Width of each avatar
+ const AVATAR_OVERLAP = 10; // How much each avatar overlaps
+ const EFFECTIVE_AVATAR_WIDTH = AVATAR_WIDTH - AVATAR_OVERLAP; // Width each additional avatar takes
+ const BUTTON_GAP = 12; // Gap between avatars and button (0.75em)
+
+ // Get all reaction types from the state.
+ const reactionTypes =
+ state.reactions && state.reactions[ postId ] ? Object.keys( state.reactions[ postId ] ) : [];
+
+ // Process each reaction group.
+ reactionTypes.forEach( ( reactionType ) => {
+ if ( ! state.reactions?.[ postId ][ reactionType ]?.items?.length ) {
+ return;
+ }
+
+ getElement()
+ .ref.querySelectorAll( `.reaction-group[data-reaction-type="${ reactionType }"]` )
+ .forEach( ( container ) => {
+ const label = container.querySelector( '.reaction-label' );
+ const labelWidth = label.offsetWidth || 0;
+ const availableWidth = container.offsetWidth - labelWidth - BUTTON_GAP;
+
+ // Calculate how many avatars can fit.
+ // The first avatar takes full width, the rest take effective width.
+ let maxAvatars = 1; // Start with 1 for the first avatar.
+
+ // If we have space for more than one avatar.
+ if ( availableWidth > AVATAR_WIDTH ) {
+ // Calculate how many additional avatars can fit in the remaining space.
+ maxAvatars += Math.floor( ( availableWidth - AVATAR_WIDTH ) / EFFECTIVE_AVATAR_WIDTH );
+ }
+
+ // Ensure we don't show more than we have.
+ const items = state.reactions[ postId ][ reactionType ].items;
+ const visibleCount = Math.min( maxAvatars, items.length );
+
+ // Update the DOM to show only the calculated number of avatars.
+ const avatarsList = container.querySelector( '.reaction-avatars' );
+ if ( avatarsList ) {
+ const avatarItems = avatarsList.querySelectorAll( 'li' );
+ avatarItems.forEach( ( item, index ) => {
+ if ( index < visibleCount ) {
+ item.removeAttribute( 'hidden' );
+ } else {
+ item.setAttribute( 'hidden', 'hidden' );
+ }
+ } );
+ }
+ } );
+ } );
+ },
+
+ /**
+ * Sets the default avatar when the avatar image fails to load.
+ *
+ * @param {Object} event The error event.
+ */
+ setDefaultAvatar( event ) {
+ event.target.src = state.defaultAvatarUrl;
+ },
+
+ /**
+ * Opens the modal with the specified reaction type.
+ */
+ onModalOpen() {
+ const context = getContext();
+ const reactionType = getElement().ref.dataset.reactionType;
+
+ // Set modal properties.
+ context.modal.items = state.reactions[ context.postId ][ reactionType ].items;
+ },
+ },
+} );
diff --git a/src/remote-reply/block.json b/src/blocks/remote-reply/block.json
similarity index 58%
rename from src/remote-reply/block.json
rename to src/blocks/remote-reply/block.json
index 3194e7e63..b8a6cdf93 100644
--- a/src/remote-reply/block.json
+++ b/src/blocks/remote-reply/block.json
@@ -2,10 +2,13 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/remote-reply",
"apiVersion": 3,
- "version": "1.0.0",
+ "version": "2.0.0",
"title": "Reply on the Fediverse",
"category": "widgets",
"description": "",
"textdomain": "activitypub",
- "viewScript": "file:./index.js"
+ "style": "file:./style-view.css",
+ "viewScriptModule": "file:./view.js",
+ "viewScript": "wp-api-fetch",
+ "render": "file:./render.php"
}
diff --git a/src/blocks/remote-reply/render.php b/src/blocks/remote-reply/render.php
new file mode 100644
index 000000000..2e7026c90
--- /dev/null
+++ b/src/blocks/remote-reply/render.php
@@ -0,0 +1,192 @@
+ ACTIVITYPUB_REST_NAMESPACE,
+ 'i18n' => array(
+ 'copied' => __( 'Copied!', 'activitypub' ),
+ 'copy' => __( 'Copy', 'activitypub' ),
+ 'emptyProfileError' => __( 'Please enter a profile URL or handle.', 'activitypub' ),
+ 'genericError' => __( 'An error occurred. Please try again.', 'activitypub' ),
+ 'invalidProfileError' => __( 'Please enter a valid URL or handle.', 'activitypub' ),
+ ),
+ )
+);
+
+// Add the block wrapper attributes.
+$wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'id' => $block_id,
+ 'class' => 'activitypub-remote-reply reply',
+ 'data-wp-interactive' => 'activitypub/remote-reply',
+ 'data-wp-init' => 'callbacks.init',
+ )
+);
+
+$wrapper_context = wp_interactivity_data_wp_context(
+ array(
+ 'blockId' => $block_id,
+ 'commentId' => $comment_id,
+ 'commentURL' => $selected_comment,
+ 'copyButtonText' => $state['i18n']['copy'],
+ 'errorMessage' => '',
+ 'hasRemoteUser' => false,
+ 'isError' => false,
+ 'isLoading' => false,
+ 'modal' => array( 'isOpen' => false ),
+ 'profileURL' => '',
+ 'remoteProfile' => '',
+ 'shouldSaveProfile' => true,
+ 'template' => '',
+ )
+);
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+>
+
+
+
+
+ __( 'Remote Reply', 'activitypub' ),
+ 'content' => $modal_content,
+ )
+ );
+ ?>
+
+ {
+ // Update button text to show success.
+ context.copyButtonText = state.i18n.copied;
+
+ // Reset button text after 1 second.
+ setTimeout( () => {
+ context.copyButtonText = state.i18n.copy;
+ }, 1000 );
+ },
+ ( error ) => {
+ // Log error if copying fails.
+ console.error( 'Could not copy text: ', error );
+ }
+ );
+ },
+
+ /**
+ * Update the remote profile value.
+ *
+ * @param {Event} event Input event.
+ * @param {String} event.target.value The remote profile value.
+ */
+ updateRemoteProfile( event ) {
+ const context = getContext();
+ context.remoteProfile = event.target.value;
+
+ // Reset error state when input changes.
+ context.isError = false;
+ context.errorMessage = '';
+ },
+
+ /**
+ * Handle keydown event for remote profile input.
+ *
+ * @param {Event} event Keydown event.
+ * @param {String} event.key Key pressed.
+ */
+ onInputKeydown( event ) {
+ if ( event.key === 'Enter' ) {
+ event.preventDefault();
+
+ return actions.submitRemoteProfile();
+ }
+ },
+
+ /**
+ * Submit the remote profile.
+ */
+ *submitRemoteProfile() {
+ const context = getContext();
+ const { namespace, i18n } = state;
+ const profileURL = context.remoteProfile.trim();
+
+ // Validate input.
+ if ( ! profileURL ) {
+ context.isError = true;
+ context.errorMessage = i18n.emptyProfileError;
+ return;
+ }
+
+ if ( ! callbacks.isHandle( profileURL ) && ! callbacks.isUrl( profileURL ) ) {
+ context.isError = true;
+ context.errorMessage = i18n.invalidProfileError;
+ return;
+ }
+
+ // Set loading state.
+ context.isLoading = true;
+ context.isError = false;
+ context.errorMessage = '';
+
+ // Construct the API path.
+ const path = `/${ namespace }/comments/${ context.commentId }/remote-reply?resource=${ encodeURIComponent(
+ profileURL
+ ) }`;
+
+ try {
+ // Make the API request.
+ const { template, url } = yield apiFetch( { path } );
+
+ // Set opening state.
+ context.isLoading = false;
+
+ // Open the remote reply URL in a new tab.
+ window.open( url, '_blank' );
+
+ // Close the modal after opening the URL.
+ actions.closeModal();
+
+ // Save the remote user if the remember option is checked.
+ if ( context.shouldSaveProfile ) {
+ callbacks.setStore( { profileURL, template } );
+ Object.assign( context, { hasRemoteUser: true, profileURL, template } );
+ }
+ } catch ( error ) {
+ // Handle error.
+ console.error( 'Error submitting profile:', error );
+ context.isLoading = false;
+ context.isError = true;
+ context.errorMessage = error.message || i18n.genericError;
+ }
+ },
+
+ /**
+ * Toggle the remember profile checkbox.
+ */
+ toggleRememberProfile() {
+ const context = getContext();
+ context.shouldSaveProfile = ! context.shouldSaveProfile;
+ },
+
+ /**
+ * Delete the saved remote user profile.
+ */
+ deleteRemoteUser() {
+ const context = getContext();
+
+ callbacks.deleteStore();
+ context.hasRemoteUser = false;
+ context.profileURL = '';
+ context.template = '';
+ },
+ },
+ callbacks: {
+ /**
+ * The storage key for the remote user data.
+ */
+ storageKey: 'fediverse-remote-user',
+
+ /**
+ * Initialize the component.
+ */
+ init() {
+ const context = getContext();
+ const { profileURL, template } = callbacks.getStore();
+
+ // Set the remote user data from localStorage if available.
+ if ( profileURL && template ) {
+ Object.assign( context, { hasRemoteUser: true, profileURL, template } );
+ }
+ },
+
+ /**
+ * Retrieve the remote user data from localStorage.
+ *
+ * @returns {Object} Remote user data or empty object, if not set.
+ */
+ getStore() {
+ const data = localStorage.getItem( callbacks.storageKey );
+
+ return data ? JSON.parse( data ) : {};
+ },
+
+ /**
+ * Store remote user data in localStorage.
+ *
+ * @param {Object} data - Remote user data to store.
+ */
+ setStore( data ) {
+ localStorage.setItem( callbacks.storageKey, JSON.stringify( data ) );
+ },
+
+ /**
+ * Remove remote user data from localStorage.
+ */
+ deleteStore() {
+ localStorage.removeItem( callbacks.storageKey );
+ },
+
+ /**
+ * Best guess whether a string is a valid ActivityPub handle.
+ *
+ * @param {string} string - String to check.
+ * @returns {boolean} True if string is a valid handle, false otherwise.
+ */
+ isHandle( string ) {
+ // Check if the string starts with '@' and contains a valid URL.
+ const parts = string.replace( /^@/, '' ).split( '@' );
+
+ return parts.length === 2 && callbacks.isUrl( `https://${ parts[ 1 ] }` );
+ },
+
+ /**
+ * Checks if a string is a valid URL.
+ *
+ * @param {string} string - String to check.
+ * @returns {boolean} True if string is a valid URL, false otherwise.
+ */
+ isUrl( string ) {
+ try {
+ new URL( string );
+ return true;
+ } catch ( _ ) {
+ return false;
+ }
+ },
+ },
+} );
diff --git a/src/reply-intent/block.json b/src/blocks/reply-intent/block.json
similarity index 76%
rename from src/reply-intent/block.json
rename to src/blocks/reply-intent/block.json
index 922cf7d98..57f56430b 100644
--- a/src/reply-intent/block.json
+++ b/src/blocks/reply-intent/block.json
@@ -3,10 +3,6 @@
"title": "Reply Handler: not a block, but block.json is very useful.",
"category": "widgets",
"icon": "admin-comments",
- "keywords": [
- "reply",
- "handler",
- "comments"
- ],
+ "keywords": [ "reply", "handler", "comments" ],
"editorScript": "file:./plugin.js"
-}
\ No newline at end of file
+}
diff --git a/src/reply-intent/plugin.js b/src/blocks/reply-intent/plugin.js
similarity index 100%
rename from src/reply-intent/plugin.js
rename to src/blocks/reply-intent/plugin.js
diff --git a/src/reply/block.json b/src/blocks/reply/block.json
similarity index 100%
rename from src/reply/block.json
rename to src/blocks/reply/block.json
diff --git a/src/reply/edit.js b/src/blocks/reply/edit.js
similarity index 89%
rename from src/reply/edit.js
rename to src/blocks/reply/edit.js
index b27afce30..4f243e124 100644
--- a/src/reply/edit.js
+++ b/src/blocks/reply/edit.js
@@ -34,7 +34,11 @@ function useIframeHeight( { html } ) {
// Try to get the scrollHeight of the body
if ( iframe.contentDocument && iframe.contentDocument.body ) {
newHeight = iframe.contentDocument.body.scrollHeight;
- } else if ( iframe.contentWindow && iframe.contentWindow.document && iframe.contentWindow.document.body ) {
+ } else if (
+ iframe.contentWindow &&
+ iframe.contentWindow.document &&
+ iframe.contentWindow.document.body
+ ) {
newHeight = iframe.contentWindow.document.body.scrollHeight;
}
} catch ( e ) {
@@ -132,7 +136,7 @@ function EmbedOverlay( { onClick } ) {
);
}
@@ -152,10 +156,11 @@ function EmbedOverlay( { onClick } ) {
* @return {boolean} Whether the HTML contains a WordPress embed.
*/
function isWordPressEmbed( html ) {
- return html && (
- html.includes('wp-embedded-content') ||
- html.includes('wp-embed/') ||
- html.includes('class="wp-embed"')
+ return (
+ html &&
+ ( html.includes( 'wp-embedded-content' ) ||
+ html.includes( 'wp-embed/' ) ||
+ html.includes( 'class="wp-embed"' ) )
);
}
@@ -227,26 +232,26 @@ function WpEmbedPreview( { html, onSelectBlock } ) {
// If no iframe was found, render the HTML directly with an overlay
if ( ! iframeProps.src ) {
return (
-
-
-
+
);
}
return (
-
+
- { ! interactive && }
+ { ! interactive && }
);
}
@@ -283,26 +288,23 @@ function ThirdPartyEmbed( { html, onClick, isSelected } ) {
}, [ html ] );
return (
-
+
@@ -323,7 +325,10 @@ function ThirdPartyEmbed( { html, onClick, isSelected } ) {
* Help text messages for different reply states.
*/
const HELP_TEXT = {
- default: __( 'Enter the URL of a post from the Fediverse (Mastodon, Pixelfed, etc.) that you want to reply to.', 'activitypub' ),
+ default: __(
+ 'Enter the URL of a post from the Fediverse (Mastodon, Pixelfed, etc.) that you want to reply to.',
+ 'activitypub'
+ ),
checking: () => (
<>
@@ -331,7 +336,7 @@ const HELP_TEXT = {
>
),
valid: __( 'The author will be notified of your response.', 'activitypub' ),
- error: __( 'This URL probably won\'t receive your reply. We\'ll still try.', 'activitypub' ),
+ error: __( 'This URL probably won’t receive your reply. We’ll still try.', 'activitypub' ),
};
/**
@@ -385,12 +390,15 @@ export default function Edit( { attributes: attr, setAttributes, clientId, isSel
}, [ optimisticEmbed ] );
// Create a stable callback that uses the ref value
- const setIsValidEmbedAndMaybeEnableEmbed = useCallback( ( isValid ) => {
- setIsValidEmbed( isValid );
- if ( optimisticEmbedRef.current && isValid ) {
- setAttributes( { embedPost: true } );
- }
- }, [ setAttributes ] );
+ const setIsValidEmbedAndMaybeEnableEmbed = useCallback(
+ ( isValid ) => {
+ setIsValidEmbed( isValid );
+ if ( optimisticEmbedRef.current && isValid ) {
+ setAttributes( { embedPost: true } );
+ }
+ },
+ [ setAttributes ]
+ );
const resetEmbedState = ( isChecking = false ) => {
setIsCheckingEmbed( isChecking );
@@ -462,7 +470,7 @@ export default function Edit( { attributes: attr, setAttributes, clientId, isSel
- ${html}
+ ${ html }