Tag and visibility handling

This commit is contained in:
Andrew Godwin 2022-11-16 06:53:39 -07:00
parent 906ed2f27c
commit 495e955378
16 changed files with 289 additions and 48 deletions

View file

@ -36,12 +36,13 @@ the less sure I am about it.
### Alpha ### Alpha
- [x] Create posts - [x] Create posts
- [ ] Set post visibility - [x] Set post visibility
- [x] Receive posts - [x] Receive posts
- [ ] Handle received post visibility - [x] Handle received post visibility (unlisted vs public only)
- [x] Receive post deletions - [x] Receive post deletions
- [ ] Receive post edits
- [x] Set content warnings on posts - [x] Set content warnings on posts
- [ ] Show content warnings on posts - [x] Show content warnings on posts
- [ ] Attach images to posts - [ ] Attach images to posts
- [ ] Receive images on posts - [ ] Receive images on posts
- [x] Create boosts - [x] Create boosts
@ -52,6 +53,7 @@ the less sure I am about it.
- [ ] Undo follows - [ ] Undo follows
- [x] Receive and accept follows - [x] Receive and accept follows
- [x] Receive follow undos - [x] Receive follow undos
- [ ] Do mentions properly
- [x] Home timeline (posts and boosts from follows) - [x] Home timeline (posts and boosts from follows)
- [ ] Notifications page (followed, boosted, liked) - [ ] Notifications page (followed, boosted, liked)
- [x] Local timeline - [x] Local timeline

View file

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

View file

@ -8,7 +8,7 @@ from django.utils import timezone
from activities.models.fan_out import FanOut from activities.models.fan_out import FanOut
from activities.models.timeline_event import TimelineEvent from activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post 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 stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow from users.models.follow import Follow
from users.models.identity import Identity from users.models.identity import Identity
@ -25,7 +25,32 @@ class PostStates(StateGraph):
""" """
Creates all needed fan-out objects for a new Post. 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 return cls.fanned_out
@ -91,6 +116,9 @@ class Post(StatorModel):
blank=True, blank=True,
) )
# Hashtags in the post
hashtags = models.JSONField(default=[])
# When the post was originally created (as opposed to when we received it) # When the post was originally created (as opposed to when we received it)
published = models.DateTimeField(default=timezone.now) published = models.DateTimeField(default=timezone.now)
@ -133,7 +161,11 @@ class Post(StatorModel):
@classmethod @classmethod
def create_local( 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": ) -> "Post":
with transaction.atomic(): with transaction.atomic():
post = cls.objects.create( post = cls.objects.create(
@ -142,6 +174,7 @@ class Post(StatorModel):
summary=summary or None, summary=summary or None,
sensitive=bool(summary), sensitive=bool(summary),
local=True, local=True,
visibility=visibility,
) )
post.object_uri = post.urls.object_uri post.object_uri = post.urls.object_uri
post.url = post.urls.view_nice post.url = post.urls.view_nice
@ -150,29 +183,6 @@ class Post(StatorModel):
### ActivityPub (outbound) ### ### 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: def to_ap(self) -> Dict:
""" """
Returns the AP JSON for this object Returns the AP JSON for this object
@ -185,7 +195,7 @@ class Post(StatorModel):
"content": self.safe_content, "content": self.safe_content,
"to": "as:Public", "to": "as:Public",
"as:sensitive": self.sensitive, "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: if self.summary:
value["summary"] = self.summary value["summary"] = self.summary
@ -236,8 +246,24 @@ class Post(StatorModel):
post.url = data.get("url", None) post.url = data.get("url", None)
post.published = parse_ld_date(data.get("published", None)) post.published = parse_ld_date(data.get("published", None))
# TODO: to # TODO: to
# TODO: mentions # Mentions and hashtags
# TODO: visibility 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() post.save()
return post return post
@ -275,9 +301,13 @@ class Post(StatorModel):
raise ValueError("Create actor does not match its Post object", data) raise ValueError("Create actor does not match its Post object", data)
# Create it # Create it
post = cls.by_ap(data["object"], create=True, update=True) 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): for follow in Follow.objects.filter(target=post.author, source__local=True):
TimelineEvent.add_post(follow.source, post) 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 # Force it into fanned_out as it's not ours
post.transition_perform(PostStates.fanned_out) post.transition_perform(PostStates.fanned_out)

View file

@ -81,6 +81,17 @@ class TimelineEvent(models.Model):
subject_post=post, subject_post=post,
)[0] )[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 @classmethod
def add_post_interaction(cls, identity, interaction): def add_post_interaction(cls, identity, interaction):
""" """

View file

@ -1,13 +1,15 @@
from django import forms
from django.shortcuts import get_object_or_404, redirect, render 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.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.decorators import identity_required
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
class Post(TemplateView): class Individual(TemplateView):
template_name = "activities/post.html" template_name = "activities/post.html"
@ -100,3 +102,44 @@ class Boost(View):
}, },
) )
return redirect(post.urls.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("/")

View file

@ -95,12 +95,9 @@ class Notifications(TemplateView):
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["events"] = ( context["events"] = TimelineEvent.objects.filter(
TimelineEvent.objects.filter( identity=self.request.identity,
identity=self.request.identity, type__in=[TimelineEvent.Types.mentioned, TimelineEvent.Types.boosted],
) ).select_related("subject_post", "subject_post__author", "subject_identity")
.exclude(type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost])
.select_related("subject_post", "subject_post__author", "subject_identity")
)
context["current_page"] = "notifications" context["current_page"] = "notifications"
return context return context

View file

@ -414,6 +414,18 @@ def canonicalise(json_data: Dict, include_security: bool = False) -> Dict:
return jsonld.compact(jsonld.expand(json_data), context) 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: def format_ld_date(value: datetime.datetime) -> str:
return value.strftime(DATETIME_FORMAT) return value.strftime(DATETIME_FORMAT)

View file

@ -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 activities.views.timelines import Home
from users.models import Identity from users.models import Identity
@ -19,3 +21,36 @@ class LoggedOutHomepage(TemplateView):
return { return {
"identities": Identity.objects.filter(local=True), "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",
},
],
}
)

View file

@ -101,7 +101,7 @@ body {
} }
main { main {
width: 850px; width: 900px;
margin: 20px auto; margin: 20px auto;
box-shadow: 0 0 50px rgba(0, 0, 0, 0.6); box-shadow: 0 0 50px rgba(0, 0, 0, 0.6);
border-radius: 5px; border-radius: 5px;
@ -520,6 +520,10 @@ h1.identity small {
color: var(--color-text-duller); color: var(--color-text-duller);
} }
.post time i {
margin-right: 3px;
}
.post .summary { .post .summary {
margin: 12px 0 4px 64px; margin: 12px 0 4px 64px;
padding: 3px 6px; padding: 3px 6px;
@ -583,3 +587,20 @@ h1.identity small {
.boost-banner a { .boost-banner a {
font-weight: bold; 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;
}
}

View file

@ -8,6 +8,7 @@ from users.views import activitypub, auth, identity
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
path("manifest.json", core.AppManifest.as_view()),
# Activity views # Activity views
path("notifications/", timelines.Notifications.as_view()), path("notifications/", timelines.Notifications.as_view()),
path("local/", timelines.Local.as_view()), path("local/", timelines.Local.as_view()),
@ -18,7 +19,8 @@ urlpatterns = [
path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()), path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()), path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts # Posts
path("@<handle>/posts/<int:post_id>/", posts.Post.as_view()), path("compose/", posts.Compose.as_view()),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()), path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()),
path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)), path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()), path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),

View file

@ -14,6 +14,10 @@
{{ event.subject_identity.name_or_handle }} followed you {{ event.subject_identity.name_or_handle }} followed you
{% elif event.type == "like" %} {% elif event.type == "like" %}
{{ event.subject_identity.name_or_handle }} liked {{ event.subject_post }} {{ 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 %} {% else %}
Unknown event type {{event.type}} Unknown event type {{event.type}}
{% endif %} {% endif %}

View file

@ -9,6 +9,15 @@
{% endif %} {% endif %}
<time> <time>
{% if post.visibility == 0 %}
<i class="visibility fa-solid fa-earth-oceania" title="Public"></i>
{% elif post.visibility == 1 %}
<i class="visibility fa-solid fa-lock-open" title="Unlisted"></i>
{% elif post.visibility == 2 %}
<i class="visibility fa-solid fa-lock" title="Followers Only"></i>
{% elif post.visibility == 3 %}
<i class="visibility fa-solid fa-at" title="Mentioned Only"></i>
{% endif %}
<a href="{{ post.url }}"> <a href="{{ post.url }}">
{% if post.published %} {% if post.published %}
{{ post.published | timedeltashort }} {{ post.published | timedeltashort }}
@ -36,6 +45,11 @@
<div class="actions"> <div class="actions">
{% include "activities/_like.html" %} {% include "activities/_like.html" %}
{% include "activities/_boost.html" %} {% include "activities/_boost.html" %}
{% if request.user.admin %}
<a title="Admin" href="/djadmin/activities/post/{{ post.pk }}/change/">
<i class="fa-solid fa-file-code"></i>
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Compose{% endblock %}
{% block content %}
<nav>
<a href="." class="selected">Compose</a>
</nav>
<form action="." method="POST">
{% csrf_token %}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
<div class="buttons">
<button>Post</button>
</div>
</form>
{% endblock %}

View file

@ -26,7 +26,7 @@
<div class="right-column"> <div class="right-column">
<h2>Compose</h2> <h2>Compose</h2>
<form action="." method="POST" class="compose"> <form action="/compose/" method="POST" class="compose">
{% csrf_token %} {% csrf_token %}
{{ form.text }} {{ form.text }}
{{ form.content_warning }} {{ form.content_warning }}

View file

@ -8,6 +8,7 @@
<link rel="stylesheet" href="{% static "css/style.css" %}" type="text/css" media="screen" /> <link rel="stylesheet" href="{% static "css/style.css" %}" type="text/css" media="screen" />
<link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" /> <link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" />
<link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" /> <link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" />
<link rel="manifest" href="/manifest.json" />
<script src="{% static "js/hyperscript.min.js" %}"></script> <script src="{% static "js/hyperscript.min.js" %}"></script>
<script src="{% static "js/htmx.min.js" %}"></script> <script src="{% static "js/htmx.min.js" %}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
@ -22,7 +23,8 @@
</a> </a>
<menu> <menu>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="#"><i class="fa-solid fa-gear"></i> Settings</a> <a href="/compose/"><i class="fa-solid fa-feather"></i> Compose</a>
<a href="/settings/"><i class="fa-solid fa-gear"></i> Settings</a>
<div class="gap"></div> <div class="gap"></div>
<a href="/identity/select/" class="identity"> <a href="/identity/select/" class="identity">
{% if not request.identity %} {% if not request.identity %}

View file

@ -44,6 +44,7 @@ class FollowAdmin(admin.ModelAdmin):
@admin.register(InboxMessage) @admin.register(InboxMessage)
class InboxMessageAdmin(admin.ModelAdmin): class InboxMessageAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "message_type", "message_actor"] list_display = ["id", "state", "state_attempted", "message_type", "message_actor"]
search_fields = ["message"]
actions = ["reset_state"] actions = ["reset_state"]
@admin.action(description="Reset State") @admin.action(description="Reset State")