summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--activities/migrations/0005_post_hashtags_alter_fanout_type_and_more.py48
-rw-r--r--activities/models/post.py90
-rw-r--r--activities/models/timeline_event.py11
-rw-r--r--activities/views/posts.py49
-rw-r--r--activities/views/timelines.py11
-rw-r--r--core/ld.py12
-rw-r--r--core/views.py37
-rw-r--r--static/css/style.css23
-rw-r--r--takahe/urls.py4
-rw-r--r--templates/activities/_event.html4
-rw-r--r--templates/activities/_post.html14
-rw-r--r--templates/activities/compose.html19
-rw-r--r--templates/activities/home.html2
-rw-r--r--templates/base.html4
-rw-r--r--users/admin.py1
16 files changed, 289 insertions, 48 deletions
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
@@ -82,6 +82,17 @@ class TimelineEvent(models.Model):
)[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):
"""
Adds a boost/like to the timeline if it's not there already.
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("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
# 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>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/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 %}
<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 }}">
{% if post.published %}
{{ post.published | timedeltashort }}
@@ -36,6 +45,11 @@
<div class="actions">
{% include "activities/_like.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>
{% endif %}
</div>
diff --git a/templates/activities/compose.html b/templates/activities/compose.html
new file mode 100644
index 0000000..ad0457b
--- /dev/null
+++ b/templates/activities/compose.html
@@ -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 %}
diff --git a/templates/activities/home.html b/templates/activities/home.html
index 2cdaaea..bfa11f7 100644
--- a/templates/activities/home.html
+++ b/templates/activities/home.html
@@ -26,7 +26,7 @@
<div class="right-column">
<h2>Compose</h2>
- <form action="." method="POST" class="compose">
+ <form action="/compose/" method="POST" class="compose">
{% csrf_token %}
{{ form.text }}
{{ form.content_warning }}
diff --git a/templates/base.html b/templates/base.html
index e465f05..553a2cc 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -8,6 +8,7 @@
<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/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/htmx.min.js" %}"></script>
{% block extra_head %}{% endblock %}
@@ -22,7 +23,8 @@
</a>
<menu>
{% 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>
<a href="/identity/select/" class="identity">
{% if not request.identity %}
diff --git a/users/admin.py b/users/admin.py
index dfd72e7..5364880 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -44,6 +44,7 @@ class FollowAdmin(admin.ModelAdmin):
@admin.register(InboxMessage)
class InboxMessageAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "message_type", "message_actor"]
+ search_fields = ["message"]
actions = ["reset_state"]
@admin.action(description="Reset State")