Merge pull request #3124 from hughrun/softblock

Allow removing followers and fix follow rejections
This commit is contained in:
Mouse Reeve 2023-12-11 19:49:45 -08:00 committed by GitHub
commit 4bfa1ca5b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 110 additions and 14 deletions

View file

@ -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)

View file

@ -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

View file

@ -43,7 +43,7 @@
</div>
{% if not minimal %}
<div class="control">
{% 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" %}
</div>
{% endif %}
</div>

View file

@ -0,0 +1,5 @@
{% load i18n %}
<form name="remove" method="post" action="/remove-follow/{{ user.id }}">
{% csrf_token %}
<button class="button is-danger is-light is-small {{ class }}" type="submit">{% trans "Remove" %}</button>
</form>

View file

@ -20,4 +20,9 @@
<li role="menuitem">
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" blocks=False %}
</li>
{% if followers_page %}
<li role="menuitem">
{% include 'snippets/remove_follower_button.html' with user=user class="is-fullwidth" %}
</li>
{% endif %}
{% endblock %}

View file

@ -25,6 +25,11 @@
</nav>
{% endblock %}
{% block panel %}
{% with followers_page=True %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block nullstate %}
<div>

View file

@ -31,7 +31,7 @@
({{ follow.username }})
</div>
<div class="column is-narrow">
{% include 'snippets/follow_button.html' with user=follow %}
{% include 'snippets/follow_button.html' with user=follow followers_page=followers_page %}
</div>
</div>
{% endfor %}

View file

@ -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(

View file

@ -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<user_id>\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"),

View file

@ -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,

View file

@ -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}")