mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-22 23:30:59 +00:00
Show post images
This commit is contained in:
parent
b13c239213
commit
716d8a766a
10 changed files with 209 additions and 10 deletions
|
@ -43,7 +43,8 @@ the less sure I am about it.
|
||||||
- [x] Receive post edits
|
- [x] Receive post edits
|
||||||
- [x] Set content warnings on posts
|
- [x] Set content warnings on posts
|
||||||
- [x] Show 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] Create boosts
|
||||||
- [x] Receive boosts
|
- [x] Receive boosts
|
||||||
- [x] Create likes
|
- [x] Create likes
|
||||||
|
@ -77,6 +78,7 @@ the less sure I am about it.
|
||||||
- [ ] Attach images to posts
|
- [ ] Attach images to posts
|
||||||
- [ ] Edit posts
|
- [ ] Edit posts
|
||||||
- [ ] Delete posts
|
- [ ] Delete posts
|
||||||
|
- [ ] Fetch remote post images locally and thumbnail
|
||||||
- [ ] Show follow pending states
|
- [ ] Show follow pending states
|
||||||
- [ ] Manual approval of followers
|
- [ ] Manual approval of followers
|
||||||
- [ ] Reply threading on post creation
|
- [ ] Reply threading on post creation
|
||||||
|
|
|
@ -1,6 +1,17 @@
|
||||||
from django.contrib import admin
|
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)
|
@admin.register(Post)
|
||||||
|
@ -8,6 +19,8 @@ class PostAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "state", "author", "created"]
|
list_display = ["id", "state", "author", "created"]
|
||||||
raw_id_fields = ["to", "mentions", "author"]
|
raw_id_fields = ["to", "mentions", "author"]
|
||||||
actions = ["force_fetch"]
|
actions = ["force_fetch"]
|
||||||
|
search_fields = ["content"]
|
||||||
|
inlines = [PostAttachmentInline]
|
||||||
readonly_fields = ["created", "updated", "object_json"]
|
readonly_fields = ["created", "updated", "object_json"]
|
||||||
|
|
||||||
@admin.action(description="Force Fetch")
|
@admin.action(description="Force Fetch")
|
||||||
|
|
69
activities/migrations/0008_postattachment.py
Normal file
69
activities/migrations/0008_postattachment.py
Normal file
|
@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,5 @@
|
||||||
from .fan_out import FanOut, FanOutStates # noqa
|
from .fan_out import FanOut, FanOutStates # noqa
|
||||||
from .post import Post, PostStates # noqa
|
from .post import Post, PostStates # noqa
|
||||||
|
from .post_attachment import PostAttachment, PostAttachmentStates # noqa
|
||||||
from .post_interaction import PostInteraction, PostInteractionStates # noqa
|
from .post_interaction import PostInteraction, PostInteractionStates # noqa
|
||||||
from .timeline_event import TimelineEvent # noqa
|
from .timeline_event import TimelineEvent # noqa
|
||||||
|
|
|
@ -146,6 +146,9 @@ class Post(StatorModel):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.author} #{self.id}"
|
return f"{self.author} #{self.id}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.urls.view
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def safe_content(self):
|
def safe_content(self):
|
||||||
return sanitize_post(self.content)
|
return sanitize_post(self.content)
|
||||||
|
@ -244,11 +247,12 @@ class Post(StatorModel):
|
||||||
raise KeyError(f"No post with ID {data['id']}", data)
|
raise KeyError(f"No post with ID {data['id']}", data)
|
||||||
if update or created:
|
if update or created:
|
||||||
post.content = sanitize_post(data["content"])
|
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.sensitive = data.get("as:sensitive", False)
|
||||||
post.url = data.get("url", None)
|
post.url = data.get("url")
|
||||||
post.published = parse_ld_date(data.get("published", None))
|
post.published = parse_ld_date(data.get("published"))
|
||||||
post.edited = parse_ld_date(data.get("updated", None))
|
post.edited = parse_ld_date(data.get("updated"))
|
||||||
|
post.in_reply_to = data.get("inReplyTo")
|
||||||
# Mentions and hashtags
|
# Mentions and hashtags
|
||||||
post.hashtags = []
|
post.hashtags = []
|
||||||
for tag in get_list(data, "tag"):
|
for tag in get_list(data, "tag"):
|
||||||
|
@ -270,6 +274,26 @@ class Post(StatorModel):
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if target.lower() == "as:public":
|
if target.lower() == "as:public":
|
||||||
post.visibility = Post.Visibilities.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()
|
post.save()
|
||||||
return post
|
return post
|
||||||
|
|
||||||
|
@ -308,8 +332,12 @@ 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 for followers
|
# Make timeline events for followers if it's not a reply
|
||||||
for follow in Follow.objects.filter(target=post.author, source__local=True):
|
# 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)
|
TimelineEvent.add_post(follow.source, post)
|
||||||
# Make timeline events for mentions if they're local
|
# Make timeline events for mentions if they're local
|
||||||
for mention in post.mentions.all():
|
for mention in post.mentions.all():
|
||||||
|
|
55
activities/models/post_attachment.py
Normal file
55
activities/models/post_attachment.py
Normal file
|
@ -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",
|
||||||
|
]
|
|
@ -36,7 +36,9 @@ class Like(View):
|
||||||
|
|
||||||
def post(self, request, handle, post_id):
|
def post(self, request, handle, post_id):
|
||||||
identity = by_handle_or_404(self.request, handle, local=False)
|
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:
|
if self.undo:
|
||||||
# Undo any likes on the post
|
# Undo any likes on the post
|
||||||
for interaction in PostInteraction.objects.filter(
|
for interaction in PostInteraction.objects.filter(
|
||||||
|
|
|
@ -39,6 +39,7 @@ class Home(FormView):
|
||||||
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
|
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
|
||||||
)
|
)
|
||||||
.select_related("subject_post", "subject_post__author")
|
.select_related("subject_post", "subject_post__author")
|
||||||
|
.prefetch_related("subject_post__attachments")
|
||||||
.order_by("-created")[:100]
|
.order_by("-created")[:100]
|
||||||
)
|
)
|
||||||
context["interactions"] = PostInteraction.get_event_interactions(
|
context["interactions"] = PostInteraction.get_event_interactions(
|
||||||
|
@ -66,6 +67,7 @@ class Local(TemplateView):
|
||||||
context["posts"] = (
|
context["posts"] = (
|
||||||
Post.objects.filter(visibility=Post.Visibilities.public, author__local=True)
|
Post.objects.filter(visibility=Post.Visibilities.public, author__local=True)
|
||||||
.select_related("author")
|
.select_related("author")
|
||||||
|
.prefetch_related("attachments")
|
||||||
.order_by("-created")[:100]
|
.order_by("-created")[:100]
|
||||||
)
|
)
|
||||||
context["current_page"] = "local"
|
context["current_page"] = "local"
|
||||||
|
@ -82,6 +84,7 @@ class Federated(TemplateView):
|
||||||
context["posts"] = (
|
context["posts"] = (
|
||||||
Post.objects.filter(visibility=Post.Visibilities.public)
|
Post.objects.filter(visibility=Post.Visibilities.public)
|
||||||
.select_related("author")
|
.select_related("author")
|
||||||
|
.prefetch_related("attachments")
|
||||||
.order_by("-created")[:100]
|
.order_by("-created")[:100]
|
||||||
)
|
)
|
||||||
context["current_page"] = "federated"
|
context["current_page"] = "federated"
|
||||||
|
|
|
@ -570,6 +570,22 @@ h1.identity small {
|
||||||
margin: 12px 0 4px 0;
|
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 {
|
.post .actions {
|
||||||
padding-left: 64px;
|
padding-left: 64px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,16 @@
|
||||||
{{ post.safe_content }}
|
{{ post.safe_content }}
|
||||||
</div>
|
</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 %}
|
{% if request.identity %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{% include "activities/_like.html" %}
|
{% include "activities/_like.html" %}
|
||||||
|
|
Loading…
Reference in a new issue