summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--activities/admin.py15
-rw-r--r--activities/migrations/0008_postattachment.py69
-rw-r--r--activities/models/__init__.py1
-rw-r--r--activities/models/post.py42
-rw-r--r--activities/models/post_attachment.py55
-rw-r--r--activities/views/posts.py4
-rw-r--r--activities/views/timelines.py3
-rw-r--r--static/css/style.css16
-rw-r--r--templates/activities/_post.html10
10 files changed, 209 insertions, 10 deletions
diff --git a/README.md b/README.md
index 5b0b0b9..7788a0d 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,8 @@ the less sure I am about it.
- [x] Receive post edits
- [x] Set content warnings on posts
- [x] Show content warnings on posts
-- [ ] Receive images on posts
+- [x] Receive images on posts
+- [x] Receive reply info
- [x] Create boosts
- [x] Receive boosts
- [x] Create likes
@@ -77,6 +78,7 @@ the less sure I am about it.
- [ ] Attach images to posts
- [ ] Edit posts
- [ ] Delete posts
+- [ ] Fetch remote post images locally and thumbnail
- [ ] Show follow pending states
- [ ] Manual approval of followers
- [ ] Reply threading on post creation
diff --git a/activities/admin.py b/activities/admin.py
index a025230..371aa7b 100644
--- a/activities/admin.py
+++ b/activities/admin.py
@@ -1,6 +1,17 @@
from django.contrib import admin
-from activities.models import FanOut, Post, PostInteraction, TimelineEvent
+from activities.models import (
+ FanOut,
+ Post,
+ PostAttachment,
+ PostInteraction,
+ TimelineEvent,
+)
+
+
+class PostAttachmentInline(admin.StackedInline):
+ model = PostAttachment
+ extra = 0
@admin.register(Post)
@@ -8,6 +19,8 @@ class PostAdmin(admin.ModelAdmin):
list_display = ["id", "state", "author", "created"]
raw_id_fields = ["to", "mentions", "author"]
actions = ["force_fetch"]
+ search_fields = ["content"]
+ inlines = [PostAttachmentInline]
readonly_fields = ["created", "updated", "object_json"]
@admin.action(description="Force Fetch")
diff --git a/activities/migrations/0008_postattachment.py b/activities/migrations/0008_postattachment.py
new file mode 100644
index 0000000..168ed58
--- /dev/null
+++ b/activities/migrations/0008_postattachment.py
@@ -0,0 +1,69 @@
+# Generated by Django 4.1.3 on 2022-11-17 05:42
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import activities.models.post_attachment
+import stator.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("activities", "0007_post_edited"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PostAttachment",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("state_ready", models.BooleanField(default=True)),
+ ("state_changed", models.DateTimeField(auto_now_add=True)),
+ ("state_attempted", models.DateTimeField(blank=True, null=True)),
+ ("state_locked_until", models.DateTimeField(blank=True, null=True)),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[("new", "new"), ("fetched", "fetched")],
+ default="new",
+ graph=activities.models.post_attachment.PostAttachmentStates,
+ max_length=100,
+ ),
+ ),
+ ("mimetype", models.CharField(max_length=200)),
+ (
+ "file",
+ models.FileField(
+ blank=True, null=True, upload_to="attachments/%Y/%m/%d/"
+ ),
+ ),
+ ("remote_url", models.CharField(blank=True, max_length=500, null=True)),
+ ("name", models.TextField(blank=True, null=True)),
+ ("width", models.IntegerField(blank=True, null=True)),
+ ("height", models.IntegerField(blank=True, null=True)),
+ ("focal_x", models.IntegerField(blank=True, null=True)),
+ ("focal_y", models.IntegerField(blank=True, null=True)),
+ ("blurhash", models.TextField(blank=True, null=True)),
+ (
+ "post",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="attachments",
+ to="activities.post",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/activities/models/__init__.py b/activities/models/__init__.py
index 48ba879..1ae3f4c 100644
--- a/activities/models/__init__.py
+++ b/activities/models/__init__.py
@@ -1,4 +1,5 @@
from .fan_out import FanOut, FanOutStates # noqa
from .post import Post, PostStates # noqa
+from .post_attachment import PostAttachment, PostAttachmentStates # noqa
from .post_interaction import PostInteraction, PostInteractionStates # noqa
from .timeline_event import TimelineEvent # noqa
diff --git a/activities/models/post.py b/activities/models/post.py
index 473755b..caa2981 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -146,6 +146,9 @@ class Post(StatorModel):
def __str__(self):
return f"{self.author} #{self.id}"
+ def get_absolute_url(self):
+ return self.urls.view
+
@property
def safe_content(self):
return sanitize_post(self.content)
@@ -244,11 +247,12 @@ class Post(StatorModel):
raise KeyError(f"No post with ID {data['id']}", data)
if update or created:
post.content = sanitize_post(data["content"])
- post.summary = data.get("summary", None)
+ post.summary = data.get("summary")
post.sensitive = data.get("as:sensitive", False)
- post.url = data.get("url", None)
- post.published = parse_ld_date(data.get("published", None))
- post.edited = parse_ld_date(data.get("updated", None))
+ post.url = data.get("url")
+ post.published = parse_ld_date(data.get("published"))
+ post.edited = parse_ld_date(data.get("updated"))
+ post.in_reply_to = data.get("inReplyTo")
# Mentions and hashtags
post.hashtags = []
for tag in get_list(data, "tag"):
@@ -270,6 +274,26 @@ class Post(StatorModel):
for target in targets:
if target.lower() == "as:public":
post.visibility = Post.Visibilities.public
+ # Attachments
+ # These have no IDs, so we have to wipe them each time
+ post.attachments.all().delete()
+ for attachment in get_list(data, "attachment"):
+ if "http://joinmastodon.org/ns#focalPoint" in attachment:
+ focal_x, focal_y = attachment[
+ "http://joinmastodon.org/ns#focalPoint"
+ ]["@list"]
+ else:
+ focal_x, focal_y = None, None
+ post.attachments.create(
+ remote_url=attachment["url"],
+ mimetype=attachment["mediaType"],
+ name=attachment.get("name"),
+ width=attachment.get("width"),
+ height=attachment.get("height"),
+ blurhash=attachment.get("http://joinmastodon.org/ns#blurhash"),
+ focal_x=focal_x,
+ focal_y=focal_y,
+ )
post.save()
return post
@@ -308,9 +332,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 for followers
- for follow in Follow.objects.filter(target=post.author, source__local=True):
- TimelineEvent.add_post(follow.source, post)
+ # Make timeline events for followers if it's not a reply
+ # TODO: _do_ show replies to people we follow somehow
+ if not post.in_reply_to:
+ 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:
diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py
new file mode 100644
index 0000000..ee77d29
--- /dev/null
+++ b/activities/models/post_attachment.py
@@ -0,0 +1,55 @@
+from django.db import models
+
+from stator.models import State, StateField, StateGraph, StatorModel
+
+
+class PostAttachmentStates(StateGraph):
+ new = State(try_interval=30000)
+ fetched = State()
+
+ new.transitions_to(fetched)
+
+ @classmethod
+ async def handle_new(cls, instance):
+ # TODO: Fetch images to our own media storage
+ pass
+
+
+class PostAttachment(StatorModel):
+ """
+ An attachment to a Post. Could be an image, a video, etc.
+ """
+
+ post = models.ForeignKey(
+ "activities.post",
+ on_delete=models.CASCADE,
+ related_name="attachments",
+ )
+
+ state = StateField(graph=PostAttachmentStates)
+
+ mimetype = models.CharField(max_length=200)
+
+ # File may not be populated if it's remote and not cached on our side yet
+ file = models.FileField(upload_to="attachments/%Y/%m/%d/", null=True, blank=True)
+
+ remote_url = models.CharField(max_length=500, null=True, blank=True)
+
+ # This is the description for images, at least
+ name = models.TextField(null=True, blank=True)
+
+ width = models.IntegerField(null=True, blank=True)
+ height = models.IntegerField(null=True, blank=True)
+ focal_x = models.IntegerField(null=True, blank=True)
+ focal_y = models.IntegerField(null=True, blank=True)
+ blurhash = models.TextField(null=True, blank=True)
+
+ def is_image(self):
+ return self.mimetype in [
+ "image/apng",
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ ]
diff --git a/activities/views/posts.py b/activities/views/posts.py
index 3ee35cc..7b93e42 100644
--- a/activities/views/posts.py
+++ b/activities/views/posts.py
@@ -36,7 +36,9 @@ class Like(View):
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
- post = get_object_or_404(identity.posts, pk=post_id)
+ post = get_object_or_404(
+ identity.posts.prefetch_related("attachments"), pk=post_id
+ )
if self.undo:
# Undo any likes on the post
for interaction in PostInteraction.objects.filter(
diff --git a/activities/views/timelines.py b/activities/views/timelines.py
index 45a0c30..ae01a45 100644
--- a/activities/views/timelines.py
+++ b/activities/views/timelines.py
@@ -39,6 +39,7 @@ class Home(FormView):
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
)
.select_related("subject_post", "subject_post__author")
+ .prefetch_related("subject_post__attachments")
.order_by("-created")[:100]
)
context["interactions"] = PostInteraction.get_event_interactions(
@@ -66,6 +67,7 @@ class Local(TemplateView):
context["posts"] = (
Post.objects.filter(visibility=Post.Visibilities.public, author__local=True)
.select_related("author")
+ .prefetch_related("attachments")
.order_by("-created")[:100]
)
context["current_page"] = "local"
@@ -82,6 +84,7 @@ class Federated(TemplateView):
context["posts"] = (
Post.objects.filter(visibility=Post.Visibilities.public)
.select_related("author")
+ .prefetch_related("attachments")
.order_by("-created")[:100]
)
context["current_page"] = "federated"
diff --git a/static/css/style.css b/static/css/style.css
index f07d78c..3c1ef49 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -570,6 +570,22 @@ h1.identity small {
margin: 12px 0 4px 0;
}
+.post .attachments {
+ margin: 10px 0 10px 64px;
+}
+
+.post .attachments a.image {
+ display: inline-block;
+ border: 3px solid var(--color-bg-menu);
+ border-radius: 3px;
+}
+
+.post .attachments a.image img {
+ display: inline-block;
+ max-width: 200px;
+ max-height: 200px;
+}
+
.post .actions {
padding-left: 64px;
}
diff --git a/templates/activities/_post.html b/templates/activities/_post.html
index 6392c89..9d8db3b 100644
--- a/templates/activities/_post.html
+++ b/templates/activities/_post.html
@@ -41,6 +41,16 @@
{{ post.safe_content }}
</div>
+ {% if post.attachments.exists %}
+ <div class="attachments">
+ {% for attachment in post.attachments.all %}
+ {% if attachment.is_image %}
+ <a href="{{ attachment.remote_url }}" class="image"><img src="{{ attachment.remote_url }}" title="{{ attachment.name }}"></a>
+ {% endif %}
+ {% endfor %}
+ </div>
+ {% endif %}
+
{% if request.identity %}
<div class="actions">
{% include "activities/_like.html" %}