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 %}
+
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}")