diff --git a/activities/migrations/0001_initial.py b/activities/migrations/0001_initial.py index 82a9085..f7961df 100644 --- a/activities/migrations/0001_initial.py +++ b/activities/migrations/0001_initial.py @@ -60,6 +60,7 @@ class Migration(migrations.Migration): models.IntegerField( choices=[ (0, "Public"), + (4, "Local Only"), (1, "Unlisted"), (2, "Followers"), (3, "Mentioned"), diff --git a/activities/models/post.py b/activities/models/post.py index dd82bcf..f75c526 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -64,6 +64,7 @@ class Post(StatorModel): class Visibilities(models.IntegerChoices): public = 0 + local_only = 4 unlisted = 1 followers = 2 mentioned = 3 @@ -261,6 +262,9 @@ class Post(StatorModel): mentions.add(identity) if reply_to: mentions.add(reply_to.author) + # Maintain local-only for replies + if reply_to.visibility == reply_to.Visibilities.local_only: + visibility = reply_to.Visibilities.local_only # Strip all HTML and apply linebreaks filter content = linebreaks_filter(strip_html(content)) # Make the Post object @@ -361,11 +365,12 @@ class Post(StatorModel): reply_post = await self.ain_reply_to_post() if reply_post: targets.add(reply_post.author) - # If this is a remote post, filter to only include local identities - if not self.local: + # If this is a remote post or local-only, filter to only include + # local identities + if not self.local or self.visibility == Post.Visibilities.local_only: targets = {target for target in targets if target.local} # If it's a local post, include the author - else: + if self.local: targets.add(self.author) return targets diff --git a/activities/views/posts.py b/activities/views/posts.py index 8cd91a1..e1609cc 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -172,6 +172,7 @@ class Compose(FormView): visibility = forms.ChoiceField( choices=[ (Post.Visibilities.public, "Public"), + (Post.Visibilities.local_only, "Local Only"), (Post.Visibilities.unlisted, "Unlisted"), (Post.Visibilities.followers, "Followers & Mentioned Only"), (Post.Visibilities.mentioned, "Mentioned Only"), @@ -207,7 +208,7 @@ class Compose(FormView): ] = self.request.identity.config_identity.default_post_visibility if self.reply_to: initial["reply_to"] = self.reply_to.pk - initial["visibility"] = Post.Visibilities.unlisted + initial["visibility"] = self.reply_to.visibility initial["text"] = f"@{self.reply_to.author.handle} " return initial diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 06aa3f7..ebe5696 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -15,6 +15,8 @@ {% elif post.visibility == 3 %} + {% elif post.visibility == 4 %} + {% endif %} {% if post.published %} diff --git a/tests/activities/models/test_post_targets.py b/tests/activities/models/test_post_targets.py new file mode 100644 index 0000000..1e4bbc4 --- /dev/null +++ b/tests/activities/models/test_post_targets.py @@ -0,0 +1,107 @@ +import pytest +from asgiref.sync import async_to_sync + +from activities.models import Post +from users.models import Follow + + +@pytest.mark.django_db +def test_post_targets_simple(identity, other_identity, remote_identity): + """ + Tests that a simple top level post returns the correct targets. + """ + # Test a post with no mentions targets author + post = Post.objects.create( + content="

Hello

", + author=identity, + local=True, + ) + targets = async_to_sync(post.aget_targets)() + assert targets == {identity} + + # Test remote reply targets original post author + Post.objects.create( + content="

Reply

", + author=remote_identity, + local=False, + in_reply_to=post.absolute_object_uri(), + ) + targets = async_to_sync(post.aget_targets)() + assert targets == {identity} + + # Test a post with local and remote mentions + post = Post.objects.create( + content="

Hello @test and @other

", + author=identity, + local=True, + ) + # Mentions are targeted + post.mentions.add(remote_identity) + post.mentions.add(other_identity) + targets = async_to_sync(post.aget_targets)() + # Targets everyone + assert targets == {identity, other_identity, remote_identity} + + # Test remote post with mentions + post.local = False + post.save() + targets = async_to_sync(post.aget_targets)() + # Only targets locals + assert targets == {identity, other_identity} + + +@pytest.mark.django_db +def test_post_local_only(identity, other_identity, remote_identity): + """ + Tests that a simple top level post returns the correct targets. + """ + # Test a short username (remote) + post = Post.objects.create( + content="

Hello @test and @other

", + author=identity, + local=True, + visibility=Post.Visibilities.local_only, + ) + post.mentions.add(remote_identity) + post.mentions.add(other_identity) + + # Remote mention is not targeted + post.mentions.add(remote_identity) + targets = async_to_sync(post.aget_targets)() + assert targets == {identity, other_identity} + + +@pytest.mark.django_db +def test_post_followers(identity, other_identity, remote_identity): + + Follow.objects.create(source=other_identity, target=identity) + Follow.objects.create(source=remote_identity, target=identity) + + # Test Public post w/o mentions targets self and followers + post = Post.objects.create( + content="

Hello

", + author=identity, + local=True, + visibility=Post.Visibilities.public, + ) + targets = async_to_sync(post.aget_targets)() + assert targets == {identity, other_identity, remote_identity} + + # Remote post only targets local followers + post.local = False + post.save() + targets = async_to_sync(post.aget_targets)() + assert targets == {identity, other_identity} + + # Local Only post only targets local followers + post.local = True + post.visibility = Post.Visibilities.local_only + post.save() + targets = async_to_sync(post.aget_targets)() + assert targets == {identity, other_identity} + + # Mentioned posts do not target unmentioned followers + post.visibility = Post.Visibilities.mentioned + post.save() + targets = async_to_sync(post.aget_targets)() + assert targets == {identity} diff --git a/tests/conftest.py b/tests/conftest.py index 48ee95a..d506c5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,11 +68,16 @@ def user() -> User: @pytest.fixture @pytest.mark.django_db -def identity(user): +def domain() -> Domain: + return Domain.objects.create(domain="example.com", local=True, public=True) + + +@pytest.fixture +@pytest.mark.django_db +def identity(user, domain) -> Identity: """ Creates a basic test identity with a user and domain. """ - domain = Domain.objects.create(domain="example.com", local=True, public=True) identity = Identity.objects.create( actor_uri="https://example.com/@test@example.com/", username="test", @@ -84,9 +89,25 @@ def identity(user): return identity +@pytest.fixture +def other_identity(user, domain) -> Identity: + """ + Creates a different basic test identity with a user and domain. + """ + identity = Identity.objects.create( + actor_uri="https://example.com/@other@example.com/", + username="other", + domain=domain, + name="Other User", + local=True, + ) + identity.users.set([user]) + return identity + + @pytest.fixture @pytest.mark.django_db -def remote_identity(): +def remote_identity() -> Identity: """ Creates a basic remote test identity with a domain. """