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 }} + {% if post.attachments.exists %} +
+ {% for attachment in post.attachments.all %} + {% if attachment.is_image %} + + {% endif %} + {% endfor %} +
+ {% endif %} + {% if request.identity %}
{% include "activities/_like.html" %}