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 --- README.md | 8 +- ...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 +-- core/ld.py | 12 +++ core/views.py | 37 ++++++++- static/css/style.css | 23 +++++- takahe/urls.py | 4 +- templates/activities/_event.html | 4 + templates/activities/_post.html | 14 ++++ templates/activities/compose.html | 19 +++++ templates/activities/home.html | 2 +- templates/base.html | 4 +- users/admin.py | 1 + 16 files changed, 289 insertions(+), 48 deletions(-) create mode 100644 activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py create mode 100644 templates/activities/compose.html diff --git a/README.md b/README.md index 435aa54..db94116 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,13 @@ the less sure I am about it. ### Alpha - [x] Create posts -- [ ] Set post visibility +- [x] Set post visibility - [x] Receive posts -- [ ] Handle received post visibility +- [x] Handle received post visibility (unlisted vs public only) - [x] Receive post deletions +- [ ] Receive post edits - [x] Set content warnings on posts -- [ ] Show content warnings on posts +- [x] Show content warnings on posts - [ ] Attach images to posts - [ ] Receive images on posts - [x] Create boosts @@ -52,6 +53,7 @@ the less sure I am about it. - [ ] Undo follows - [x] Receive and accept follows - [x] Receive follow undos +- [ ] Do mentions properly - [x] Home timeline (posts and boosts from follows) - [ ] Notifications page (followed, boosted, liked) - [x] Local timeline 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 diff --git a/core/ld.py b/core/ld.py index 346708c..6692dab 100644 --- a/core/ld.py +++ b/core/ld.py @@ -414,6 +414,18 @@ def canonicalise(json_data: Dict, include_security: bool = False) -> Dict: return jsonld.compact(jsonld.expand(json_data), context) +def get_list(container, key) -> List: + """ + Given a JSON-LD value (that can be either a list, or a dict if it's just + one item), always returns a list""" + if key not in container: + return [] + value = container[key] + if not isinstance(value, list): + return [value] + return value + + def format_ld_date(value: datetime.datetime) -> str: return value.strftime(DATETIME_FORMAT) diff --git a/core/views.py b/core/views.py index 30eaf90..2ef83cc 100644 --- a/core/views.py +++ b/core/views.py @@ -1,4 +1,6 @@ -from django.views.generic import TemplateView +from django.http import JsonResponse +from django.templatetags.static import static +from django.views.generic import TemplateView, View from activities.views.timelines import Home from users.models import Identity @@ -19,3 +21,36 @@ class LoggedOutHomepage(TemplateView): return { "identities": Identity.objects.filter(local=True), } + + +class AppManifest(View): + """ + Serves a PWA manifest file. This is a view as we want to drive some + items from settings. + """ + + def get(self, request): + return JsonResponse( + { + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "name": "Takahē", + "short_name": "Takahē", + "start_url": "/", + "display": "standalone", + "background_color": "#26323c", + "theme_color": "#26323c", + "description": "An ActivityPub server", + "icons": [ + { + "src": static("img/icon-128.png"), + "sizes": "128x128", + "type": "image/png", + }, + { + "src": static("img/icon-1024.png"), + "sizes": "1024x1024", + "type": "image/png", + }, + ], + } + ) diff --git a/static/css/style.css b/static/css/style.css index a7f3ef3..c791023 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -101,7 +101,7 @@ body { } main { - width: 850px; + width: 900px; margin: 20px auto; box-shadow: 0 0 50px rgba(0, 0, 0, 0.6); border-radius: 5px; @@ -520,6 +520,10 @@ h1.identity small { color: var(--color-text-duller); } +.post time i { + margin-right: 3px; +} + .post .summary { margin: 12px 0 4px 64px; padding: 3px 6px; @@ -583,3 +587,20 @@ h1.identity small { .boost-banner a { font-weight: bold; } + + + +@media (max-width: 920px) or (display-mode: standalone) { + + main { + width: 100%; + margin: 0; + box-shadow: none; + border-radius: 0; + } + + header .logo { + border-radius: 0; + } + +} diff --git a/takahe/urls.py b/takahe/urls.py index a87ec87..723516a 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -8,6 +8,7 @@ from users.views import activitypub, auth, identity urlpatterns = [ path("", core.homepage), + path("manifest.json", core.AppManifest.as_view()), # Activity views path("notifications/", timelines.Notifications.as_view()), path("local/", timelines.Local.as_view()), @@ -18,7 +19,8 @@ urlpatterns = [ path("@/actor/inbox/", activitypub.Inbox.as_view()), path("@/action/", identity.ActionIdentity.as_view()), # Posts - path("@/posts//", posts.Post.as_view()), + path("compose/", posts.Compose.as_view()), + path("@/posts//", posts.Individual.as_view()), path("@/posts//like/", posts.Like.as_view()), path("@/posts//unlike/", posts.Like.as_view(undo=True)), path("@/posts//boost/", posts.Boost.as_view()), diff --git a/templates/activities/_event.html b/templates/activities/_event.html index bbe0ae5..375e475 100644 --- a/templates/activities/_event.html +++ b/templates/activities/_event.html @@ -14,6 +14,10 @@ {{ event.subject_identity.name_or_handle }} followed you {% elif event.type == "like" %} {{ event.subject_identity.name_or_handle }} liked {{ event.subject_post }} + {% elif event.type == "mentioned" %} + {{ event.subject_post.author.name_or_handle }} mentioned you in {{ event.subject_post }} + {% elif event.type == "boosted" %} + {{ event.subject_identity.name_or_handle }} boosted your post {{ event.subject_post }} {% else %} Unknown event type {{event.type}} {% endif %} diff --git a/templates/activities/_post.html b/templates/activities/_post.html index d05f7ad..6392c89 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -9,6 +9,15 @@ {% endif %}