diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 00c9524fe..a365f4cc0 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -171,9 +171,19 @@ class Reject(Verb): type: str = "Reject" def action(self, allow_external_connections=True): - """reject a follow request""" - obj = self.object.to_model(save=False, allow_create=False) - obj.reject() + """reject a follow or follow request""" + + for model_name in ["UserFollowRequest", "UserFollows", None]: + model = apps.get_model(f"bookwyrm.{model_name}") if model_name else None + if obj := self.object.to_model( + model=model, + save=False, + allow_create=False, + allow_external_connections=allow_external_connections, + ): + # Reject the first model that can be built. + obj.reject() + break @dataclass(init=False) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 7af6ad5ab..3386a02dc 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -65,6 +65,13 @@ class UserRelationship(BookWyrmModel): base_path = self.user_subject.remote_id return f"{base_path}#follows/{self.id}" + def get_accept_reject_id(self, status): + """get id for sending an accept or reject of a local user""" + + base_path = self.user_object.remote_id + status_id = self.id or 0 + return f"{base_path}#{status}/{status_id}" + class UserFollows(ActivityMixin, UserRelationship): """Following a user""" @@ -105,6 +112,20 @@ class UserFollows(ActivityMixin, UserRelationship): ) return obj + def reject(self): + """generate a Reject for this follow. This would normally happen + when a user deletes a follow they previously accepted""" + + if self.user_object.local: + activity = activitypub.Reject( + id=self.get_accept_reject_id(status="rejects"), + actor=self.user_object.remote_id, + object=self.to_activity(), + ).serialize() + self.broadcast(activity, self.user_object) + + self.delete() + class UserFollowRequest(ActivitypubMixin, UserRelationship): """following a user requires manual or automatic confirmation""" @@ -148,13 +169,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): if not manually_approves: self.accept() - def get_accept_reject_id(self, status): - """get id for sending an accept or reject of a local user""" - - base_path = self.user_object.remote_id - status_id = self.id or 0 - return f"{base_path}#{status}/{status_id}" - def accept(self, broadcast_only=False): """turn this request into the real deal""" user = self.user_object diff --git a/bookwyrm/templates/snippets/follow_button.html b/bookwyrm/templates/snippets/follow_button.html index 2bde47f58..28b979987 100644 --- a/bookwyrm/templates/snippets/follow_button.html +++ b/bookwyrm/templates/snippets/follow_button.html @@ -43,7 +43,7 @@ {% if not minimal %}
- {% include 'snippets/user_options.html' with user=user class="is-small" %} + {% include 'snippets/user_options.html' with user=user followers_page=followers_page class="is-small" %}
{% endif %} diff --git a/bookwyrm/templates/snippets/remove_follower_button.html b/bookwyrm/templates/snippets/remove_follower_button.html new file mode 100644 index 000000000..28bef6842 --- /dev/null +++ b/bookwyrm/templates/snippets/remove_follower_button.html @@ -0,0 +1,5 @@ +{% load i18n %} +
+ {% csrf_token %} + +
diff --git a/bookwyrm/templates/snippets/user_options.html b/bookwyrm/templates/snippets/user_options.html index 35abc98c2..0e15e413a 100644 --- a/bookwyrm/templates/snippets/user_options.html +++ b/bookwyrm/templates/snippets/user_options.html @@ -20,4 +20,9 @@
  • {% include 'snippets/block_button.html' with user=user class="is-fullwidth" blocks=False %}
  • +{% if followers_page %} +
  • + {% include 'snippets/remove_follower_button.html' with user=user class="is-fullwidth" %} +
  • +{% endif %} {% endblock %} diff --git a/bookwyrm/templates/user/relationships/followers.html b/bookwyrm/templates/user/relationships/followers.html index 267f55706..99446c40f 100644 --- a/bookwyrm/templates/user/relationships/followers.html +++ b/bookwyrm/templates/user/relationships/followers.html @@ -25,6 +25,11 @@ {% endblock %} +{% block panel %} + {% with followers_page=True %} + {{ block.super }} + {% endwith %} +{% endblock %} {% block nullstate %}
    diff --git a/bookwyrm/templates/user/relationships/layout.html b/bookwyrm/templates/user/relationships/layout.html index 44732bfa1..b3b85db66 100644 --- a/bookwyrm/templates/user/relationships/layout.html +++ b/bookwyrm/templates/user/relationships/layout.html @@ -31,7 +31,7 @@ ({{ follow.username }})
    - {% include 'snippets/follow_button.html' with user=follow %} + {% include 'snippets/follow_button.html' with user=follow followers_page=followers_page %}
    {% endfor %} diff --git a/bookwyrm/tests/views/test_follow.py b/bookwyrm/tests/views/test_follow.py index 1a311b413..e70ace769 100644 --- a/bookwyrm/tests/views/test_follow.py +++ b/bookwyrm/tests/views/test_follow.py @@ -177,13 +177,39 @@ class FollowViews(TestCase): user_subject=self.remote_user, user_object=self.local_user ) - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + with patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ) as broadcast_mock: views.delete_follow_request(request) + # did we send the reject activity? + activity = json.loads(broadcast_mock.call_args[1]["args"][1]) + self.assertEqual(activity["actor"], self.local_user.remote_id) + self.assertEqual(activity["object"]["object"], rel.user_object.remote_id) + self.assertEqual(activity["type"], "Reject") # request should be deleted self.assertEqual(models.UserFollowRequest.objects.filter(id=rel.id).count(), 0) # follow relationship should not exist self.assertEqual(models.UserFollows.objects.filter(id=rel.id).count(), 0) + def test_handle_reject_existing(self, *_): + """reject a follow previously approved""" + request = self.factory.post("", {"user": self.remote_user.username}) + request.user = self.local_user + rel = models.UserFollows.objects.create( + user_subject=self.remote_user, user_object=self.local_user + ) + with patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ) as broadcast_mock: + views.remove_follow(request, self.remote_user.id) + # did we send the reject activity? + activity = json.loads(broadcast_mock.call_args[1]["args"][1]) + self.assertEqual(activity["actor"], self.local_user.remote_id) + self.assertEqual(activity["object"]["object"], rel.user_object.remote_id) + self.assertEqual(activity["type"], "Reject") + # follow relationship should not exist + self.assertEqual(models.UserFollows.objects.filter(id=rel.id).count(), 0) + def test_ostatus_follow_request(self, *_): """check ostatus subscribe template loads""" request = self.factory.get( diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index dd943b7b5..a059436ff 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -768,6 +768,9 @@ urlpatterns = [ # following re_path(r"^follow/?$", views.follow, name="follow"), re_path(r"^unfollow/?$", views.unfollow, name="unfollow"), + re_path( + r"^remove-follow/(?P\d+)/?$", views.remove_follow, name="remove-follow" + ), re_path(r"^accept-follow-request/?$", views.accept_follow_request), re_path(r"^delete-follow-request/?$", views.delete_follow_request), re_path(r"^ostatus_follow/?$", views.remote_follow, name="remote-follow"), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 7076eb3ed..64060a5c2 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -113,6 +113,7 @@ from .feed import DirectMessage, Feed, Replies, Status from .follow import ( follow, unfollow, + remove_follow, ostatus_follow_request, ostatus_follow_success, remote_follow, diff --git a/bookwyrm/views/follow.py b/bookwyrm/views/follow.py index 0090cbe32..dcb1c695c 100644 --- a/bookwyrm/views/follow.py +++ b/bookwyrm/views/follow.py @@ -69,6 +69,33 @@ def unfollow(request): return redirect("/") +@login_required +@require_POST +def remove_follow(request, user_id): + """remove a previously approved follower without blocking them""" + + to_remove = get_object_or_404(models.User, id=user_id) + + try: + models.UserFollows.objects.get( + user_subject=to_remove, user_object=request.user + ).reject() + except models.UserFollows.DoesNotExist: + clear_cache(to_remove, request.user) + + try: + models.UserFollowRequest.objects.get( + user_subject=to_remove, user_object=request.user + ).reject() + except models.UserFollowRequest.DoesNotExist: + clear_cache(to_remove, request.user) + + if is_api_request(request): + return HttpResponse() + + return redirect(f"{request.user.local_path}/followers") + + @login_required @require_POST def accept_follow_request(request): @@ -100,7 +127,7 @@ def delete_follow_request(request): ) follow_request.raise_not_deletable(request.user) - follow_request.delete() + follow_request.reject() return redirect(f"/user/{request.user.localname}")