Repository: bookwyrm-social/bookwyrm

Issue:
Cannot Cancel a Follow Request and Cannot Block a User if you have a Pending Follow Request With Them
**Describe the bug**

Once you've sent a follow request to an account, there does not appear to be a way to cancel that follow request. This removes the button that allows you to block that user

**To Reproduce**
Steps to reproduce the behavior:
1. On account A, set `Manually approve follower` to true
2. From account B, go to A's profile click the `Send Follow Request` button
3. Don't approve the follow request. B can no longer block A

**Expected behavior**

The `Send Follow Request` button should turn into a `Cancel Follow Request` button and should maintain the menu button for blocking users

**Desktop (please complete the following information):**
 - OS: macOs
 - Browser Firefox
 - Version 87.0b9

[WIP] Cancel follow requests
Ok, I'm going to stop trying to pretend I should know all this - having never worked with Python nor ActivityPub before - and just publish a draft PR 😅 If someone has the det to help, that would be much appreciated, otherwise, I'll leave this here while I go read a bunch of docs 📖

So, here's what I've got so far. I seem to be able to cancel requests locally, but...

1. ...I'm not really sure about the incoming thing, nor how to test it. There is a `handle_unfollow` already, but it doesn't appear to be called (because I'm testing locally), so I don't know if it would work and my brain stopped for a bit. 🙅🧠
2. ...after I send a follow request, the button goes to `Unfollow` and requires a refresh to go to `Cancel request`. I'm assuming this is because `user.follower_requests.all` isn't updated, but I don't know how Django works yet, so.. To the docs!📚

Also, does 5e0aa1b  make sense, or no? Seemed to work in a repl, but I, again, don't know a lot about Python, so.. 🤷

Closes #282 


Discussion:
#426 is related to this
What you've got so far looks right to me! this is rad :)

> ...I'm not really sure about the incoming thing, nor how to test it. There is a handle_unfollow already, but it doesn't appear to be called (because I'm testing locally), so I don't know if it would work and my brain stopped for a bit. 🙅🧠

all of the functionality in `incoming` is called in celery. from looking at it just now, I think `handle_unfollow` should be called when the Undo activity is received; you'll need to have celery, redis, nginx, and ngrok all up and running to receive incoming activities from the outside world (and if any of those are causing problems, I'm super happy to help). 

I haven't figured out a super great way to test incoming, and what I usually do is interact with my mastodon account (so in this case, making a follow request and then cancelling it from mastodon). In ngrok, you can see the incoming request ActivityPub json which I find really helpful for debugging.

In terms of unit testing, it's a lot more straightforward because you can just directly call the function without using celery -- there's a test for  `handle_unfollow` that might be a useful reference point? https://github.com/mouse-reeve/bookwyrm/blob/main/bookwyrm/tests/test_incoming.py#L167

> ...after I send a follow request, the button goes to Unfollow and requires a refresh to go to Cancel request. I'm assuming this is because user.follower_requests.all isn't updated, but I don't know how Django works yet, so.. To the docs!📚

There's a bit of javascript that intercepts the form submission when you press the follow button (https://github.com/mouse-reeve/bookwyrm/blob/main/bookwyrm/static/js/shared.js#L1). It hides the follow button and displays the unfollow button instead. I think the solution here is that the button you added to `follow_buttons.html` should be in an if/else block with the unfollow button, following the same pattern as here: https://github.com/mouse-reeve/bookwyrm/blob/main/bookwyrm/templates/snippets/follow_button.html#L13 and have the same class that determines if it's visible or not on page load (`class="follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}"`) that's on the unfollow button. So it's not an issue of a python variable not being updated in Django, it's just an issue of my weird javascript and markup choices.


> Also, does 5e0aa1b make sense, or no? Seemed to work in a repl, but I, again, don't know a lot about Python, so.. 🤷

it makes sense!
> what I usually do is interact with my mastodon account

omg, I forgot that I can do that, this is so cool! 🤩 (admittedly, took me a big ol' while to figure out how. turns out, including the protocol in `DOMAIN` in `.env` breaks things..)

I haven't looked into if it's possible yet, but should cancelling a follow request also undo the 'follow request'-notification?

Thanks for the response, it helps a lot 🙏

Edit: Hol' up! If the federated request cancelling happens in `handle_unfollow`, should the local really have its own endpoint?

Edit again: _(cw: If you've got a lot on your mind, don't read this ramble. It might make sense soon, but I have to go do other stuff, so half asking for help, half noting where I left off so my mind can pick it up later.)_ If I'm understanding templates correctly, they load on page load and don't update dynaically somehow? So.. how do we differentiate between `Unfollow` and `Cancel request` if we don't get the updated `user.follower_requests.all`? 😳 We can't just go with `user.manually_approves_followers`, because that can be true when even if they've accepted our request, and we can't go with not being in `user.followers.all`, because that's what we use to choose which form to show..
>  If I'm understanding templates correctly, they load on page load and don't update dynaically somehow? So.. how do we differentiate between `Unfollow` and `Cancel request` if we don't get the updated `user.follower_requests.all`? 😳 We can't just go with `user.manually_approves_followers`, because that can be true when even if they've accepted our request, and we can't go with not being in `user.followers.all`, because that's what we use to choose which form to show..

Yeah, the templates are completely rendered server-side in python, so there's nothing dynamic about them. You're right that neither or those will tell you enough to figure out what button to display, but you can use `user.follow_request.all` the same way that you can use `user.followers.all`, which will tell you if there's a pending request

something like:
```html
{% if user.manually_approves_followers %}
 < .... cancel follow request button, with the class hidden set by {% if not request.user in user.follow_requests.all %} ... >
{% else %}
<form action="/unfollow/" method="POST" onsubmit="interact(event)" class="follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
    {% csrf_token %}
    <input type="hidden" name="user" value="{{ user.username }}">
    <button class="button is-small is-danger is-light" type="submit">Unfollow</button>
</form>
{% endif %}

...
```

> I haven't looked into if it's possible yet, but should cancelling a follow request also undo the 'follow request'-notification?

yes, it should delete the notification. `handle_delete_status` in `incoming` does notification deletion as well, plus I think a couple other places

> Edit: Hol' up! If the federated request cancelling happens in `handle_unfollow`, should the local really have its own endpoint?

I just checked how this works in mastodon because I wasn't sure! Apparently the activity that sends for unfollow and cancel request are, as far as I can tell, functionally identical. So I think the best path forward is to have `handle_unfollow` check for an existing `follow` relationship and for an existing `follow_request` relationship, and behave accordingly

this is the activity I got from mastodon:
```json
{
    "@context": "https://www.w3.org/ns/activitystreams",
    "id": "https://friend.camp/users/tripofmice#follows/1234/undo",
    "type": "Undo",
    "actor": "https://friend.camp/users/tripofmice",
    "object": {
        "id": "https://friend.camp/blah-blah-blah-uuid",
        "type": "Follow",
        "actor": "https://friend.camp/users/tripofmice",
        "object": "https://ngrok-domain.ngrok.io/user/mouse"
    }
}
```
Hey, ehm, sorry I'm blanking on this. A lot of life happening and I'm kinda bad at Python, so takes a while to try to figure things out on my own. If someone ends up wanting to take this issue, go wild, otherwise, I might drop by and finish up when things calm down 😊

Resolving patch:
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -69,7 +69,13 @@ def action(self):
         model = None
         if self.object.type == "Follow":
             model = apps.get_model("bookwyrm.UserFollows")
-        obj = self.object.to_model(model=model, save=False, allow_create=False)
+            obj = self.object.to_model(model=model, save=False, allow_create=False)
+            if not obj:
+                # this could be a folloq request not a follow proper
+                model = apps.get_model("bookwyrm.UserFollowRequest")
+                obj = self.object.to_model(model=model, save=False, allow_create=False)
+        else:
+            obj = self.object.to_model(model=model, save=False, allow_create=False)
         if not obj:
             # if we don't have the object, we can't undo it. happens a lot with boosts
             return
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -188,8 +188,8 @@
     re_path(r"^start-reading/(?P<book_id>\d+)/?$", views.start_reading),
     re_path(r"^finish-reading/(?P<book_id>\d+)/?$", views.finish_reading),
     # following
-    re_path(r"^follow/?$", views.follow),
-    re_path(r"^unfollow/?$", views.unfollow),
+    re_path(r"^follow/?$", views.follow, name="follow"),
+    re_path(r"^unfollow/?$", views.unfollow, name="unfollow"),
     re_path(r"^accept-follow-request/?$", views.accept_follow_request),
     re_path(r"^delete-follow-request/?$", views.delete_follow_request),
 ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/bookwyrm/views/follow.py b/bookwyrm/views/follow.py
--- a/bookwyrm/views/follow.py
+++ b/bookwyrm/views/follow.py
@@ -40,10 +40,22 @@ def unfollow(request):
     except models.User.DoesNotExist:
         return HttpResponseBadRequest()
 
-    models.UserFollows.objects.get(
-        user_subject=request.user, user_object=to_unfollow
-    ).delete()
-    return redirect(to_unfollow.local_path)
+    try:
+        models.UserFollows.objects.get(
+            user_subject=request.user, user_object=to_unfollow
+        ).delete()
+    except models.UserFollows.DoesNotExist:
+        pass
+
+    try:
+        models.UserFollowRequest.objects.get(
+            user_subject=request.user, user_object=to_unfollow
+        ).delete()
+    except models.UserFollowRequest.DoesNotExist:
+        pass
+
+    # this is handled with ajax so it shouldn't really matter
+    return redirect(request.headers.get("Referer", "/"))
 
 
 @login_required


Test patch:
diff --git a/bookwyrm/tests/views/test_follow.py b/bookwyrm/tests/views/test_follow.py
--- a/bookwyrm/tests/views/test_follow.py
+++ b/bookwyrm/tests/views/test_follow.py
@@ -1,5 +1,7 @@
 """ test for app action functionality """
+import json
 from unittest.mock import patch
+
 from django.contrib.auth.models import Group, Permission
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
@@ -117,6 +119,8 @@ def test_handle_unfollow(self):
         with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
             views.unfollow(request)
             self.assertEqual(mock.call_count, 1)
+            activity = json.loads(mock.call_args_list[0][0][1])
+            self.assertEqual(activity["type"], "Undo")
 
         self.assertEqual(self.remote_user.followers.count(), 0)
 
diff --git a/bookwyrm/tests/views/test_inbox.py b/bookwyrm/tests/views/test_inbox.py
--- a/bookwyrm/tests/views/test_inbox.py
+++ b/bookwyrm/tests/views/test_inbox.py
@@ -275,6 +275,36 @@ def test_handle_follow_manually_approved(self):
         follow = models.UserFollows.objects.all()
         self.assertEqual(list(follow), [])
 
+    def test_handle_undo_follow_request(self):
+        """ the requester cancels a follow request """
+        self.local_user.manually_approves_followers = True
+        self.local_user.save(broadcast=False)
+        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
+            request = models.UserFollowRequest.objects.create(
+                user_subject=self.remote_user, user_object=self.local_user
+            )
+        self.assertTrue(self.local_user.follower_requests.exists())
+
+        activity = {
+            "type": "Undo",
+            "id": "bleh",
+            "to": ["https://www.w3.org/ns/activitystreams#Public"],
+            "cc": ["https://example.com/user/mouse/followers"],
+            "actor": self.remote_user.remote_id,
+            "@context": "https://www.w3.org/ns/activitystreams",
+            "object": {
+                "@context": "https://www.w3.org/ns/activitystreams",
+                "id": request.remote_id,
+                "type": "Follow",
+                "actor": "https://example.com/users/rat",
+                "object": "https://example.com/user/mouse",
+            },
+        }
+
+        views.inbox.activity_task(activity)
+
+        self.assertFalse(self.local_user.follower_requests.exists())
+
     def test_handle_unfollow(self):
         """ remove a relationship """
         with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):