[COMPONENTS][Subscription] Subscribe Actor action implemented

[TWIG] AddProfileAction event added
[CARDS][Profile] Refactor and restyling to accomodate Actor actions
This commit is contained in:
Eliseu Amaro 2022-01-05 17:39:10 +00:00 committed by Diogo Peralta Cordeiro
parent 0d1ab2c9cf
commit 0c245fcb6e
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
12 changed files with 404 additions and 65 deletions

View File

@ -23,9 +23,19 @@ declare(strict_types = 1);
namespace Component\Subscription\Controller;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Collection\Util\ActorControllerTrait;
use Component\Collection\Util\Controller\CircleController;
use Component\Subscription\Subscription as SubscriptionComponent;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
@ -58,4 +68,100 @@ class Subscribers extends CircleController
],
);
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\NotFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function subscribersAdd(Request $request, int $object_id): array
{
$subject = Common::ensureLoggedIn();
$object = Actor::getById($object_id);
$form = Form::create(
[
['subscriber_add', SubmitType::class, ['label' => _m('Subscribe!')]],
],
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!\is_null(SubscriptionComponent::subscribe($subject, $object))) {
DB::flush();
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
}
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$object_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
}
// TODO anchor on element id
throw new RedirectException(url: $from);
}
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
return [
'_template' => 'subscription/add_subscriber.html.twig',
'form' => $form->createView(),
'object' => $object,
];
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\NotFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function subscribersRemove(Request $request, int $object_id): array
{
$subject = Common::ensureLoggedIn();
$object = Actor::getById($object_id);
$form = Form::create(
[
['subscriber_remove', SubmitType::class, ['label' => _m('Unsubscribe')]],
],
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!\is_null(SubscriptionComponent::unsubscribe($subject, $object))) {
DB::flush();
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
}
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$object_id} attempted to subscribe an actor and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
}
// TODO anchor on element id
throw new RedirectException(url: $from);
}
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
return [
'_template' => 'subscription/remove_subscriber.html.twig',
'form' => $form->createView(),
'object' => $object,
];
}
}

View File

@ -23,49 +23,84 @@ declare(strict_types = 1);
namespace Component\Subscription;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use App\Util\Nickname;
use Component\Subscription\Controller\Subscribers;
use Component\Subscription\Controller\Subscriptions;
use Component\Subscription\Controller\Subscribers as SubscribersController;
use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
use Symfony\Component\HttpFoundation\Request;
class Subscription extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect(id: 'actor_subscriptions_id', uri_path: '/actor/{id<\d+>}/subscriptions', target: [Subscriptions::class, 'subscriptionsByActorId']);
$r->connect(id: 'actor_subscriptions_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscriptions', target: [Subscriptions::class, 'subscriptionsByActorNickname']);
$r->connect(id: 'actor_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [Subscribers::class, 'subscribersByActorId']);
$r->connect(id: 'actor_subscribers_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscribers', target: [Subscribers::class, 'subscribersByActorNickname']);
$r->connect(id: 'actor_subscribe_add', uri_path: '/actor/subscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersAdd']);
$r->connect(id: 'actor_subscribe_remove', uri_path: '/actor/unsubscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersRemove']);
$r->connect(id: 'actor_subscriptions_id', uri_path: '/actor/{id<\d+>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorId']);
$r->connect(id: 'actor_subscriptions_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorNickname']);
$r->connect(id: 'actor_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [SubscribersController::class, 'subscribersByActorId']);
$r->connect(id: 'actor_subscribers_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscribers', target: [SubscribersController::class, 'subscribersByActorNickname']);
return Event::next;
}
/**
* Persists a new Subscription Entity from Subscriber to Subject (Actor being subscribed) and Activity
* To use after Subscribe/Unsubscribe and DB::flush()
*
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
*/
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
{
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
$subscribed_id = \is_int($object) ? $object : $object->getId();
$cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']);
$cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']);
return [$cache_subscriber,$cache_subscribed];
}
/**
* Persists a new Subscription Entity from Subject to Object (Actor being subscribed) and Activity
*
* A new notification is then handled, informing all interested Actors of this action
*
* @param Actor|int|LocalUser $subject The actor performing the subscription
* @param Actor|int|LocalUser $object The target of the subscription
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return null|Activity a new Activity if changes were made
*
* @see self::refreshSubscriptionCount() to delete cache after this action
*/
public static function subscribe(int|Actor|LocalUser $subscriber, int|Actor|LocalUser $subject, string $source = 'web'): ?Activity
public static function subscribe(int|Actor|LocalUser $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
{
$subscriber_id = \is_int($subscriber) ? $subscriber : $subscriber->getId();
$subscribed_id = \is_int($subject) ? $subject : $subject->getId();
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
$subscribed_id = \is_int($object) ? $object : $object->getId();
$opts = [
'subscriber_id' => $subscriber_id,
'subscribed_id' => $subscribed_id,
];
$subscription = DB::findOneBy(table: \Component\Subscription\Entity\Subscription::class, criteria: $opts, return_null: true);
$subscription = DB::findOneBy(table: Entity\Subscription::class, criteria: $opts, return_null: true);
$activity = null;
if (\is_null($subscription)) {
DB::persist(\Component\Subscription\Entity\Subscription::create($opts));
DB::persist(Entity\Subscription::create($opts));
$activity = Activity::create([
'actor_id' => $subscriber_id,
'verb' => 'subscribe',
@ -76,31 +111,40 @@ class Subscription extends Component
DB::persist($activity);
Event::handle('NewNotification', [
$actor = ($subscriber instanceof Actor ? $subscriber : Actor::getById($subscribed_id)),
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['object' => [$subscribed_id]],
_m('{nickname} subscribed to {subject}.', ['{actor}' => $actor->getId(), '{subject}' => $activity->getObjectId()]),
['object' => [$activity->getObjectId()]],
_m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
]);
}
return $activity;
}
/**
* Removes the Subscription Entity created beforehand, by the same Actor, and on the same subject
* Removes the Subscription Entity created beforehand, by the same Actor, and on the same object
*
* Informs all interested Actors of this action, handling out the NewNotification event
*
* @param Actor|int|LocalUser $subject The actor undoing the subscription
* @param Actor|int|LocalUser $object The target of the subscription
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return null|Activity a new Activity if changes were made
*
* @see self::refreshSubscriptionCount() to delete cache after this action
*/
public static function unsubscribe(int|Actor|LocalUser $subscriber, int|Actor|LocalUser $subject, string $source = 'web'): ?Activity
public static function unsubscribe(int|Actor|LocalUser $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
{
$subscriber_id = \is_int($subscriber) ? $subscriber : $subscriber->getId();
$subscribed_id = \is_int($subject) ? $subject : $subject->getId();
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
$subscribed_id = \is_int($object) ? $object : $object->getId();
$opts = [
'subscriber_id' => $subscriber_id,
'subscribed_id' => $subscribed_id,
];
$subscription = DB::findOneBy(table: \Component\Subscription\Entity\Subscription::class, criteria: $opts, return_null: true);
$subscription = DB::findOneBy(table: Entity\Subscription::class, criteria: $opts, return_null: true);
$activity = null;
if (!\is_null($subscription)) {
// Remove Subscription
@ -115,7 +159,88 @@ class Subscription extends Component
'source' => $source,
]);
DB::persist($activity);
Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['object' => [$previous_follow_activity->getObjectId()]],
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
]);
}
return $activity;
}
/**
* Provides ``\App\templates\cards\profile\view.html.twig`` an **additional action** to be performed **on the given
* Actor** (which the profile card of is currently being rendered).
*
* In the case of ``\App\Component\Subscription``, the action added allows a **LocalUser** to **subscribe** or
* **unsubscribe** a given **Actor**.
*
* @param Actor $object The Actor on which the action is to be performed
* @param array $actions An array containing all actions added to the
* current profile, this event adds an action to it
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return bool EventHook
*/
public function onAddProfileActions(Request $request, Actor $object, array &$actions): bool
{
// Action requires a user to be logged in
// We know it's a LocalUser, which has the same id as Actor
// We don't want the Actor to unfollow itself
if ((\is_null($subject = Common::user())) || ($subject->getId() === $object->getId())) {
return Event::next;
}
// Let's retrieve from here this subject came from to redirect it to previous location
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
// Who is the subject attempting to subscribe to?
$object_id = $object->getId();
// The id of both the subject and object
$opts = [
'subscriber_id' => $subject->getId(),
'subscribed_id' => $object_id,
];
// If subject is not subbed to object already, then route it to add subscription
// Else, route to remove subscription
$subscribe_action_url = ($not_subscribed_already = \is_null(DB::findOneBy(table: Entity\Subscription::class, criteria: $opts, return_null: true))) ? Router::url(
'actor_subscribe_add',
[
'object_id' => $object_id,
'from' => $from . '#profile-' . $object_id,
],
Router::ABSOLUTE_PATH,
) : Router::url(
'actor_subscribe_remove',
[
'object_id' => $object_id,
'from' => $from . '#profile-' . $object_id,
],
Router::ABSOLUTE_PATH,
);
// Finally, create an array with proper keys set accordingly
// to provide Profile Card template, the info it needs in order to render it properly
$action_extra_class = $not_subscribed_already ? 'add-actor-button-container' : 'remove-actor-button-container';
$title = $not_subscribed_already ? 'Subscribe ' . $object->getNickname() : 'Unsubscribe ' . $object->getNickname();
$subscribe_action = [
'url' => $subscribe_action_url,
'title' => _m($title),
'classes' => 'button-container note-actions-unset ' . $action_extra_class,
'id' => 'add-actor-button-container-' . $object_id,
];
$actions[] = $subscribe_action;
return Event::next;
}
}

View File

@ -0,0 +1,8 @@
{% extends 'base.html.twig' %}
{% block body %}
{% block profile_view %}
{% include 'cards/profile/view.html.twig' with { actor: object } %}
{% endblock profile_view %}
{{ form(form) }}
{% endblock body %}

View File

@ -0,0 +1,8 @@
{% extends 'base.html.twig' %}
{% block body %}
{% block profile_view %}
{% include 'cards/profile/view.html.twig' with { actor: object } %}
{% endblock profile_view %}
{{ form(form) }}
{% endblock body %}

View File

@ -160,6 +160,9 @@ embed header {
display: flex;
vertical-align: middle;
}
.note-actions ul > li {
margin-left: 8px;
}
.note-actions-extra-details {
display: flex;
flex-direction: column;
@ -201,12 +204,14 @@ embed header {
mask-repeat: no-repeat !important;
mask-size: cover !important;
display: inline-block;
margin-left: var(--s);
width: var(--unit);
height: var(--unit);
background-color: var(--foreground);
opacity: 0.33;
}
.button-container:not(:first-of-type) {
margin-left: var(--s);
}
.button-container:focus,
.button-container:hover {
border: none !important;

View File

@ -1,38 +1,89 @@
.profile {
display: flex;
flex-direction: column;
flex-wrap: wrap;
font-family: 'Open Sans',sans-serif;
margin-bottom: var(--s);
border: 2px solid var(--border);
border-radius: var(--s);
padding: var(--s);
padding: var(--unit);
background: var(--gradient) !important;
box-shadow: var(--shadow);
}
.profile *[class*="profile-info-"] {
flex: 1;
.profile header {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
vertical-align: middle;
}
.profile-info {
display: flex;
flex-wrap: wrap;
flex-direction: column;
}
.profile-info-nickname {
.profile-info-url {
display: block;
}
.profile-info-url-nickname {
font-size: var(--m);
}
.profile-info-tags {
margin: unset;
.profile-info-url-remote {
opacity: 0.66;
}
.profile-info-stats strong {
margin-right: 5px;
.profile-info-url > * {
display: block;
}
.profile-info-stats {
margin-top: var(--s);
.profile-stats {
align-self: center;
margin-left: auto;
text-align: right;
}
.profile-info-bio,
.profile-info-nickname {
.profile-stats-subscriptions,
.profile-stats-subscribers {
display: block;
}
.profile-stats-subscriptions strong,
.profile-stats-subscribers strong {
margin-right: 4px;
}
.profile-info-url,
.profile-bio {
word-break: break-all;
}
.profile-bio {
margin-top: 4px;
}
.profile-tags {
margin: unset;
margin-top: 4px;
}
.button-container {
border: none !important;
mask-repeat: no-repeat !important;
mask-size: cover !important;
display: inline-block;
width: var(--unit);
height: var(--unit);
background-color: var(--foreground);
opacity: 0.33;
}
.button-container:not(:first-of-type) {
margin-left: var(--s);
}
.button-container:focus,
.button-container:hover {
border: none !important;
mask-repeat: no-repeat !important;
mask-size: cover !important;
opacity: 1;
background-color: var(--accent);
}
.add-actor-button-container {
-o-mask-image: url("../../icons/add-actor.svg");
-moz-mask-image: url("../../icons/add-actor.svg");
-webkit-mask-image: url("../../icons/add-actor.svg");
mask-image: url("../../icons/add-actor.svg");
}
.remove-actor-button-container {
-o-mask-image: url("../../icons/remove-actor.svg");
-moz-mask-image: url("../../icons/remove-actor.svg");
-webkit-mask-image: url("../../icons/remove-actor.svg");
mask-image: url("../../icons/remove-actor.svg");
}
.avatar {
max-width: 4rem;
max-height: 4rem;

View File

@ -0,0 +1,5 @@
<!-- https://github.com/primer/octicons -->
<!-- MIT License -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill-rule="evenodd" d="M19.25 1a.75.75 0 01.75.75V4h2.25a.75.75 0 010 1.5H20v2.25a.75.75 0 01-1.5 0V5.5h-2.25a.75.75 0 010-1.5h2.25V1.75a.75.75 0 01.75-.75zM9 6a3.5 3.5 0 100 7 3.5 3.5 0 000-7zM4 9.5a5 5 0 117.916 4.062 7.973 7.973 0 015.018 7.166.75.75 0 11-1.499.044 6.469 6.469 0 00-12.932 0 .75.75 0 01-1.499-.044 7.973 7.973 0 015.059-7.181A4.993 4.993 0 014 9.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@ -0,0 +1,14 @@
<!-- https://github.com/primer/octicons -->
<!-- MIT License -->
<svg
viewBox="0 0 24 24"
width="24"
height="24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="m 22.25,4 c 1,0 1,1.5 0,1.5 -3.829871,-0.01723 0.571664,0 -6,0 -1,0 -1,-1.5 0,-1.5 3.712329,4.776e-4 -0.745902,0 6,0 z M 9,6 c -4.6666646,0 -4.6666646,7 0,7 4.666665,0 4.666665,-7 0,-7 z M 4,9.5 C 4.000267,5.7603244 7.9555419,3.344606 11.282679,5.0518864 14.609816,6.7591667 14.953845,11.381021 11.916,13.562 c 2.950793,1.175822 4.922134,3.991013 5.018,7.166 0.05218,1.020644 -1.491226,1.065947 -1.499,0.044 -0.106459,-3.49441 -2.969971,-6.272008 -6.466,-6.272008 -3.4960292,0 -6.3595411,2.777598 -6.466,6.272008 -0.049962,0.977219 -1.50644332,0.934467 -1.499,-0.044 0.096826,-3.19055 2.0872357,-6.015839 5.059,-7.181 C 4.7659785,12.607017 3.9986894,11.101821 4,9.5 Z"
id="path924" />
</svg>

After

Width:  |  Height:  |  Size: 958 B

View File

@ -259,7 +259,7 @@ class Actor extends Entity
'fullname' => "actor-fullname-id-{$actor_id}",
'self-tags' => "actor-self-tags-{$actor_id}",
'circles' => "actor-circles-{$actor_id}",
'subscriber' => "subscriber-{$actor_id}",
'subscribers' => "subscribers-{$actor_id}",
'subscribed' => "subscribed-{$actor_id}",
'relative-nickname' => "actor-{$actor_id}-relative-nickname-{$other}", // $other is $nickname
'can-admin' => "actor-{$actor_id}-can-admin-{$other}", // $other is an actor id
@ -340,7 +340,7 @@ class Actor extends Entity
public function getSubscribersCount(): int
{
return $this->getSubCount(which: 'subscriber', column: 'subscribed_id');
return $this->getSubCount(which: 'subscribers', column: 'subscribed_id');
}
public function getSubscribedCount(): int

View File

@ -65,6 +65,7 @@ class Extension extends AbstractExtension
new TwigFunction('config', [Runtime::class, 'getConfig']),
new TwigFunction('dd', 'dd'),
new TwigFunction('die', 'die'),
new TwigFunction('get_profile_actions', [Runtime::class, 'getProfileActions']),
new TwigFunction('get_extra_note_actions', [Runtime::class, 'getExtraNoteActions']),
new TwigFunction('get_feeds', [Runtime::class, 'getFeeds']),
new TwigFunction('get_note_actions', [Runtime::class, 'getNoteActions']),

View File

@ -67,6 +67,13 @@ class Runtime implements RuntimeExtensionInterface, EventSubscriberInterface
return F\some($routes, F\partial_left([Formatting::class, 'startsWith'], $current_route)) ? $class : '';
}
public function getProfileActions(Actor $actor)
{
$actions = [];
Event::handle('AddProfileActions', [$this->request, $actor, &$actions]);
return $actions;
}
public function getNoteActions(Note $note)
{
$actions = [];

View File

@ -10,34 +10,43 @@
{% block profile_view %}
<section id='profile-{{ actor.id }}' class='profile' title="{{ actor_nickname }}'s {{ 'profile information.' | trans }}">
<a href="{{ actor_uri }}">
<header>
<div class="profile-info">
<img src="{{ actor_avatar }}" class="avatar" alt="{% trans %} %actor_nickname%'s avatar. {% endtrans %}" width="{{actor_avatar_dimensions['width']}}" height="{{actor_avatar_dimensions['height']}}">
<strong class="profile-info-nickname" title="{{ actor_nickname }}{{ '\'s nickname.' | trans }}">{{ actor_nickname }}</strong>
</div>
</a>
<img src="{{ actor_avatar }}" class="profile-avatar avatar" alt="{% trans %} %actor_nickname%'s avatar. {% endtrans %}" width="{{actor_avatar_dimensions['width']}}" height="{{actor_avatar_dimensions['height']}}">
<div>
<a class="profile-info-url" href="{{ actor_uri }}">
<strong class="profile-info-url-nickname" title="{{ actor_nickname }}{{ '\'s nickname.' | trans }}">{{ actor_nickname }}</strong>
{% if not actor_is_local %}
<section class="profile-info-remote">
<span>{{ mention }}</span>
</section>
<span class="profile-info-url-remote">{{ mention }}</span>
{% endif %}
<section class="profile-info-bio">
</a>
<ul class="profile-info-actions">
{% for current_action in get_profile_actions(actor) %}
<li>
<a title="{{ current_action["title"] | trans }}"
class="{{ current_action["classes"] }}"
href="{{ current_action["url"] }}"></a>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="profile-stats">
<span class="profile-stats-subscriptions"><strong><a href="{{ actor.getSubscriptionsUrl() }}">{{ 'Subscribed' | trans }}</a></strong>{{ actor.getSubscribedCount() }}</span>
<span class="profile-stats-subscribers"><strong><a href="{{ actor.getSubscribersUrl() }}">{{ 'Subscribers' | trans }}</a></strong>{{ actor.getSubscribersCount() }}</span>
</div>
</header>
<div>
<section class="profile-bio">
<span>{{ actor.getBio() }}</span>
</section>
<section class="profile-info-stats">
<div><strong><a href="{{ actor.getSubscriptionsUrl() }}">{{ 'Subscribed' | trans }}</a></strong>{{ actor.getSubscribedCount() }}</div>
<div><strong><a href="{{ actor.getSubscribersUrl() }}">{{ 'Subscribers' | trans }}</a></strong>{{ actor.getSubscribersCount() }}</div>
</section>
<nav class="profile-info-tags">
<nav class="profile-tags">
{% for tag in actor_tags %}
{% include 'cards/tag/actor_tag.html.twig' with { 'tag': tag, 'actor': actor } %}
{% endfor %}
</nav>
</div>
{% for block in handle_event('AppendCardProfile', { 'actor': actor }) %}
{{ block | raw }}
{% endfor %}