From 495e955378d62dc439c4c210785e5d401bc77f64 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Wed, 16 Nov 2022 06:53:39 -0700 Subject: Tag and visibility handling --- ...005_post_hashtags_alter_fanout_type_and_more.py | 48 ++++++++++++ activities/models/post.py | 90 ++++++++++++++-------- activities/models/timeline_event.py | 11 +++ activities/views/posts.py | 49 +++++++++++- activities/views/timelines.py | 11 +-- 5 files changed, 169 insertions(+), 40 deletions(-) create mode 100644 activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py (limited to 'activities') diff --git a/activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py b/activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py new file mode 100644 index 0000000..07d5cca --- /dev/null +++ b/activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.1.3 on 2022-11-16 20:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "activities", + "0004_rename_authored_post_published_alter_fanout_type_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="hashtags", + field=models.JSONField(default=[]), + ), + migrations.AlterField( + model_name="fanout", + name="type", + field=models.CharField( + choices=[ + ("post", "Post"), + ("interaction", "Interaction"), + ("undo_interaction", "Undo Interaction"), + ], + max_length=100, + ), + ), + migrations.AlterField( + model_name="timelineevent", + name="type", + field=models.CharField( + choices=[ + ("post", "Post"), + ("boost", "Boost"), + ("mentioned", "Mentioned"), + ("liked", "Liked"), + ("followed", "Followed"), + ("boosted", "Boosted"), + ], + max_length=100, + ), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index 22e6412..4896e58 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -8,7 +8,7 @@ from django.utils import timezone from activities.models.fan_out import FanOut from activities.models.timeline_event import TimelineEvent from core.html import sanitize_post -from core.ld import canonicalise, format_ld_date, parse_ld_date +from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date from stator.models import State, StateField, StateGraph, StatorModel from users.models.follow import Follow from users.models.identity import Identity @@ -25,7 +25,32 @@ class PostStates(StateGraph): """ Creates all needed fan-out objects for a new Post. """ - await instance.afan_out() + post = await instance.afetch_full() + # Non-local posts should not be here + if not post.local: + raise ValueError("Trying to run handle_new on a non-local post!") + # Build list of targets - mentions always included + targets = set() + async for mention in post.mentions.all(): + targets.add(mention) + # Then, if it's not mentions only, also deliver to followers + if post.visibility != Post.Visibilities.mentioned: + async for follower in post.author.inbound_follows.select_related("source"): + targets.add(follower.source) + # Fan out to each one + for follow in targets: + await FanOut.objects.acreate( + identity=follow, + type=FanOut.Types.post, + subject_post=post, + ) + # And one for themselves if they're local + if post.author.local: + await FanOut.objects.acreate( + identity_id=post.author_id, + type=FanOut.Types.post, + subject_post=post, + ) return cls.fanned_out @@ -91,6 +116,9 @@ class Post(StatorModel): blank=True, ) + # Hashtags in the post + hashtags = models.JSONField(default=[]) + # When the post was originally created (as opposed to when we received it) published = models.DateTimeField(default=timezone.now) @@ -133,7 +161,11 @@ class Post(StatorModel): @classmethod def create_local( - cls, author: Identity, content: str, summary: Optional[str] = None + cls, + author: Identity, + content: str, + summary: Optional[str] = None, + visibility: int = Visibilities.public, ) -> "Post": with transaction.atomic(): post = cls.objects.create( @@ -142,6 +174,7 @@ class Post(StatorModel): summary=summary or None, sensitive=bool(summary), local=True, + visibility=visibility, ) post.object_uri = post.urls.object_uri post.url = post.urls.view_nice @@ -150,29 +183,6 @@ class Post(StatorModel): ### ActivityPub (outbound) ### - async def afan_out(self): - """ - Creates FanOuts for a new post - """ - # Send a copy to all people who follow this user - post = await self.afetch_full() - async for follow in post.author.inbound_follows.select_related( - "source", "target" - ): - if follow.source.local or follow.target.local: - await FanOut.objects.acreate( - identity_id=follow.source_id, - type=FanOut.Types.post, - subject_post=post, - ) - # And one for themselves if they're local - if post.author.local: - await FanOut.objects.acreate( - identity_id=post.author_id, - type=FanOut.Types.post, - subject_post=post, - ) - def to_ap(self) -> Dict: """ Returns the AP JSON for this object @@ -185,7 +195,7 @@ class Post(StatorModel): "content": self.safe_content, "to": "as:Public", "as:sensitive": self.sensitive, - "url": self.urls.view_nice if self.local else self.url, + "url": str(self.urls.view_nice if self.local else self.url), } if self.summary: value["summary"] = self.summary @@ -236,8 +246,24 @@ class Post(StatorModel): post.url = data.get("url", None) post.published = parse_ld_date(data.get("published", None)) # TODO: to - # TODO: mentions - # TODO: visibility + # Mentions and hashtags + post.hashtags = [] + for tag in get_list(data, "tag"): + if tag["type"].lower() == "mention": + mention_identity = Identity.by_actor_uri(tag["href"], create=True) + post.mentions.add(mention_identity) + elif tag["type"].lower() == "as:hashtag": + post.hashtags.append(tag["name"].lstrip("#")) + else: + raise ValueError(f"Unknown tag type {tag['type']}") + # Visibility and to + # (a post is public if it's ever to/cc as:Public, otherwise we + # regard it as unlisted for now) + targets = get_list(data, "to") + get_list(data, "cc") + post.visibility = Post.Visibilities.unlisted + for target in targets: + if target.lower() == "as:public": + post.visibility = Post.Visibilities.public post.save() return post @@ -275,9 +301,13 @@ class Post(StatorModel): raise ValueError("Create actor does not match its Post object", data) # Create it post = cls.by_ap(data["object"], create=True, update=True) - # Make timeline events as appropriate + # Make timeline events for followers for follow in Follow.objects.filter(target=post.author, source__local=True): TimelineEvent.add_post(follow.source, post) + # Make timeline events for mentions if they're local + for mention in post.mentions.all(): + if mention.local: + TimelineEvent.add_mentioned(mention, post) # Force it into fanned_out as it's not ours post.transition_perform(PostStates.fanned_out) diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index 29dec19..368fdad 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -81,6 +81,17 @@ class TimelineEvent(models.Model): subject_post=post, )[0] + @classmethod + def add_mentioned(cls, identity, post): + """ + Adds a mention of identity by post + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.mentioned, + subject_post=post, + )[0] + @classmethod def add_post_interaction(cls, identity, interaction): """ diff --git a/activities/views/posts.py b/activities/views/posts.py index ece7cf3..3ee35cc 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -1,13 +1,15 @@ +from django import forms from django.shortcuts import get_object_or_404, redirect, render +from django.template.defaultfilters import linebreaks_filter from django.utils.decorators import method_decorator -from django.views.generic import TemplateView, View +from django.views.generic import FormView, TemplateView, View -from activities.models import PostInteraction, PostInteractionStates +from activities.models import Post, PostInteraction, PostInteractionStates from users.decorators import identity_required from users.shortcuts import by_handle_or_404 -class Post(TemplateView): +class Individual(TemplateView): template_name = "activities/post.html" @@ -100,3 +102,44 @@ class Boost(View): }, ) return redirect(post.urls.view) + + +@method_decorator(identity_required, name="dispatch") +class Compose(FormView): + + template_name = "activities/compose.html" + + class form_class(forms.Form): + text = forms.CharField( + widget=forms.Textarea( + attrs={ + "placeholder": "What's on your mind?", + }, + ) + ) + visibility = forms.ChoiceField( + choices=[ + (Post.Visibilities.public, "Public"), + (Post.Visibilities.unlisted, "Unlisted"), + (Post.Visibilities.followers, "Followers & Mentioned Only"), + (Post.Visibilities.mentioned, "Mentioned Only"), + ], + ) + content_warning = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + "placeholder": "Content Warning", + }, + ), + help_text="Optional - Post will be hidden behind this text until clicked", + ) + + def form_valid(self, form): + Post.create_local( + author=self.request.identity, + content=linebreaks_filter(form.cleaned_data["text"]), + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + ) + return redirect("/") diff --git a/activities/views/timelines.py b/activities/views/timelines.py index c59c3b6..45a0c30 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -95,12 +95,9 @@ class Notifications(TemplateView): def get_context_data(self): context = super().get_context_data() - context["events"] = ( - TimelineEvent.objects.filter( - identity=self.request.identity, - ) - .exclude(type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost]) - .select_related("subject_post", "subject_post__author", "subject_identity") - ) + context["events"] = TimelineEvent.objects.filter( + identity=self.request.identity, + type__in=[TimelineEvent.Types.mentioned, TimelineEvent.Types.boosted], + ).select_related("subject_post", "subject_post__author", "subject_identity") context["current_page"] = "notifications" return context -- cgit v1.2.3