diff --git a/components/Subscription/Controller/Subscribers.php b/components/Subscription/Controller/Subscribers.php index 137cb1731c..034259e6dc 100644 --- a/components/Subscription/Controller/Subscribers.php +++ b/components/Subscription/Controller/Subscribers.php @@ -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, + ]; + } } diff --git a/components/Subscription/Subscription.php b/components/Subscription/Subscription.php index 9945627856..865d859baf 100644 --- a/components/Subscription/Subscription.php +++ b/components/Subscription/Subscription.php @@ -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; + } } diff --git a/components/Subscription/templates/subscription/add_subscriber.html.twig b/components/Subscription/templates/subscription/add_subscriber.html.twig new file mode 100644 index 0000000000..b3fe738612 --- /dev/null +++ b/components/Subscription/templates/subscription/add_subscriber.html.twig @@ -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 %} diff --git a/components/Subscription/templates/subscription/remove_subscriber.html.twig b/components/Subscription/templates/subscription/remove_subscriber.html.twig new file mode 100644 index 0000000000..b3fe738612 --- /dev/null +++ b/components/Subscription/templates/subscription/remove_subscriber.html.twig @@ -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 %} diff --git a/public/assets/default_theme/css/pages/feeds.css b/public/assets/default_theme/css/pages/feeds.css index 7eb76050c8..cbbc45f69d 100644 --- a/public/assets/default_theme/css/pages/feeds.css +++ b/public/assets/default_theme/css/pages/feeds.css @@ -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; diff --git a/public/assets/default_theme/css/widgets/sections.css b/public/assets/default_theme/css/widgets/sections.css index 2261473155..a4fa88ab3c 100644 --- a/public/assets/default_theme/css/widgets/sections.css +++ b/public/assets/default_theme/css/widgets/sections.css @@ -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; diff --git a/public/assets/default_theme/icons/add-actor.svg b/public/assets/default_theme/icons/add-actor.svg new file mode 100644 index 0000000000..65c0757fa3 --- /dev/null +++ b/public/assets/default_theme/icons/add-actor.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/assets/default_theme/icons/remove-actor.svg b/public/assets/default_theme/icons/remove-actor.svg new file mode 100644 index 0000000000..a9e4da191f --- /dev/null +++ b/public/assets/default_theme/icons/remove-actor.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/src/Entity/Actor.php b/src/Entity/Actor.php index 9b821b5973..5333436870 100644 --- a/src/Entity/Actor.php +++ b/src/Entity/Actor.php @@ -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 diff --git a/src/Twig/Extension.php b/src/Twig/Extension.php index 9f5a25f93f..3a1adead60 100644 --- a/src/Twig/Extension.php +++ b/src/Twig/Extension.php @@ -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']), diff --git a/src/Twig/Runtime.php b/src/Twig/Runtime.php index 6ee8ab8039..0e036dbcfa 100644 --- a/src/Twig/Runtime.php +++ b/src/Twig/Runtime.php @@ -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 = []; diff --git a/templates/cards/profile/view.html.twig b/templates/cards/profile/view.html.twig index 4b8ff79ff1..533bd52ab2 100644 --- a/templates/cards/profile/view.html.twig +++ b/templates/cards/profile/view.html.twig @@ -10,34 +10,43 @@ {% block profile_view %}
- +
- {% trans %} %actor_nickname%'s avatar. {% endtrans %} - {{ actor_nickname }} + {% trans %} %actor_nickname%'s avatar. {% endtrans %} +
+ + {{ actor_nickname }} + {% if not actor_is_local %} + {{ mention }} + {% endif %} + +
    + {% for current_action in get_profile_actions(actor) %} +
  • + +
  • + {% endfor %} +
+
- - - {% if not actor_is_local %} -
- {{ mention }} +
+ {{ 'Subscribed' | trans }}{{ actor.getSubscribedCount() }} + {{ 'Subscribers' | trans }}{{ actor.getSubscribersCount() }} +
+
+
+
+ {{ actor.getBio() }}
- {% endif %} - -
- {{ actor.getBio() }} -
- -
-
{{ 'Subscribed' | trans }}{{ actor.getSubscribedCount() }}
-
{{ 'Subscribers' | trans }}{{ actor.getSubscribersCount() }}
-
- - + +
{% for block in handle_event('AppendCardProfile', { 'actor': actor }) %} {{ block | raw }} {% endfor %}