mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-29 02:31:00 +00:00
Image attachment uploads
This commit is contained in:
parent
a826ae18ea
commit
6f2f28a3a7
16 changed files with 418 additions and 166 deletions
|
@ -25,6 +25,11 @@ class HashtagAdmin(admin.ModelAdmin):
|
||||||
instance.transition_perform("outdated")
|
instance.transition_perform("outdated")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PostAttachment)
|
||||||
|
class PostAttachmentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "post", "created"]
|
||||||
|
|
||||||
|
|
||||||
class PostAttachmentInline(admin.StackedInline):
|
class PostAttachmentInline(admin.StackedInline):
|
||||||
model = PostAttachment
|
model = PostAttachment
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
54
activities/migrations/0003_postattachment_null_thumb.py
Normal file
54
activities/migrations/0003_postattachment_null_thumb.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
# Generated by Django 4.1.3 on 2022-12-01 23:42
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import core.uploads
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("activities", "0002_hashtag"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="postattachment",
|
||||||
|
name="created",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="postattachment",
|
||||||
|
name="thumbnail",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=functools.partial(
|
||||||
|
core.uploads.upload_namer, *("attachment_thumbnails",), **{}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="postattachment",
|
||||||
|
name="updated",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="postattachment",
|
||||||
|
name="post",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="attachments",
|
||||||
|
to="activities.post",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,5 @@
|
||||||
import re
|
import re
|
||||||
from typing import Dict, Iterable, Optional, Set
|
from typing import Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
|
@ -312,7 +312,7 @@ class Post(StatorModel):
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
await Post.objects.select_related("author", "author__domain")
|
await Post.objects.select_related("author", "author__domain")
|
||||||
.prefetch_related("mentions", "mentions__domain")
|
.prefetch_related("mentions", "mentions__domain", "attachments")
|
||||||
.aget(pk=self.pk)
|
.aget(pk=self.pk)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -326,6 +326,7 @@ class Post(StatorModel):
|
||||||
summary: Optional[str] = None,
|
summary: Optional[str] = None,
|
||||||
visibility: int = Visibilities.public,
|
visibility: int = Visibilities.public,
|
||||||
reply_to: Optional["Post"] = None,
|
reply_to: Optional["Post"] = None,
|
||||||
|
attachments: Optional[List] = None,
|
||||||
) -> "Post":
|
) -> "Post":
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Find mentions in this post
|
# Find mentions in this post
|
||||||
|
@ -353,6 +354,8 @@ class Post(StatorModel):
|
||||||
post.object_uri = post.urls.object_uri
|
post.object_uri = post.urls.object_uri
|
||||||
post.url = post.absolute_object_uri()
|
post.url = post.absolute_object_uri()
|
||||||
post.mentions.set(mentions)
|
post.mentions.set(mentions)
|
||||||
|
if attachments:
|
||||||
|
post.attachments.set(attachments)
|
||||||
post.save()
|
post.save()
|
||||||
return post
|
return post
|
||||||
|
|
||||||
|
@ -361,6 +364,7 @@ class Post(StatorModel):
|
||||||
content: str,
|
content: str,
|
||||||
summary: Optional[str] = None,
|
summary: Optional[str] = None,
|
||||||
visibility: int = Visibilities.public,
|
visibility: int = Visibilities.public,
|
||||||
|
attachments: Optional[List] = None,
|
||||||
):
|
):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Strip all HTML and apply linebreaks filter
|
# Strip all HTML and apply linebreaks filter
|
||||||
|
@ -371,6 +375,7 @@ class Post(StatorModel):
|
||||||
self.edited = timezone.now()
|
self.edited = timezone.now()
|
||||||
self.hashtags = Hashtag.hashtags_from_content(content) or None
|
self.hashtags = Hashtag.hashtags_from_content(content) or None
|
||||||
self.mentions.set(self.mentions_from_content(content, self.author))
|
self.mentions.set(self.mentions_from_content(content, self.author))
|
||||||
|
self.attachments.set(attachments or [])
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -421,6 +426,7 @@ class Post(StatorModel):
|
||||||
"as:sensitive": self.sensitive,
|
"as:sensitive": self.sensitive,
|
||||||
"url": self.absolute_object_uri(),
|
"url": self.absolute_object_uri(),
|
||||||
"tag": [],
|
"tag": [],
|
||||||
|
"attachment": [],
|
||||||
}
|
}
|
||||||
if self.summary:
|
if self.summary:
|
||||||
value["summary"] = self.summary
|
value["summary"] = self.summary
|
||||||
|
@ -438,11 +444,13 @@ class Post(StatorModel):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
value["cc"].append(mention.actor_uri)
|
value["cc"].append(mention.actor_uri)
|
||||||
# Remove tag and cc if they're empty
|
# Attachments
|
||||||
if not value["cc"]:
|
for attachment in self.attachments.all():
|
||||||
del value["cc"]
|
value["attachment"].append(attachment.to_ap())
|
||||||
if not value["tag"]:
|
# Remove fields if they're empty
|
||||||
del value["tag"]
|
for field in ["cc", "tag", "attachment"]:
|
||||||
|
if not value[field]:
|
||||||
|
del value[field]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def to_create_ap(self):
|
def to_create_ap(self):
|
||||||
|
|
|
@ -27,15 +27,24 @@ class PostAttachment(StatorModel):
|
||||||
"activities.post",
|
"activities.post",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="attachments",
|
related_name="attachments",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
state = StateField(graph=PostAttachmentStates)
|
state = StateField(graph=PostAttachmentStates)
|
||||||
|
|
||||||
mimetype = models.CharField(max_length=200)
|
mimetype = models.CharField(max_length=200)
|
||||||
|
|
||||||
# File may not be populated if it's remote and not cached on our side yet
|
# Files may not be populated if it's remote and not cached on our side yet
|
||||||
file = models.FileField(
|
file = models.FileField(
|
||||||
upload_to=partial(upload_namer, "attachments"), null=True, blank=True
|
upload_to=partial(upload_namer, "attachments"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
thumbnail = models.ImageField(
|
||||||
|
upload_to=partial(upload_namer, "attachment_thumbnails"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_url = models.CharField(max_length=500, null=True, blank=True)
|
remote_url = models.CharField(max_length=500, null=True, blank=True)
|
||||||
|
@ -49,6 +58,9 @@ class PostAttachment(StatorModel):
|
||||||
focal_y = models.IntegerField(null=True, blank=True)
|
focal_y = models.IntegerField(null=True, blank=True)
|
||||||
blurhash = models.TextField(null=True, blank=True)
|
blurhash = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def is_image(self):
|
def is_image(self):
|
||||||
return self.mimetype in [
|
return self.mimetype in [
|
||||||
"image/apng",
|
"image/apng",
|
||||||
|
@ -58,3 +70,30 @@ class PostAttachment(StatorModel):
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def thumbnail_url(self):
|
||||||
|
if self.thumbnail:
|
||||||
|
return self.thumbnail.url
|
||||||
|
elif self.file:
|
||||||
|
return self.file.url
|
||||||
|
else:
|
||||||
|
return self.remote_url
|
||||||
|
|
||||||
|
def full_url(self):
|
||||||
|
if self.file:
|
||||||
|
return self.file.url
|
||||||
|
else:
|
||||||
|
return self.remote_url
|
||||||
|
|
||||||
|
### ActivityPub ###
|
||||||
|
|
||||||
|
def to_ap(self):
|
||||||
|
return {
|
||||||
|
"url": self.file.url,
|
||||||
|
"name": self.name,
|
||||||
|
"type": "Document",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"mediaType": self.mimetype,
|
||||||
|
"http://joinmastodon.org/ns#focalPoint": [0.5, 0.5],
|
||||||
|
}
|
||||||
|
|
182
activities/views/compose.py
Normal file
182
activities/views/compose.py
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.generic import FormView
|
||||||
|
|
||||||
|
from activities.models import (
|
||||||
|
Post,
|
||||||
|
PostAttachment,
|
||||||
|
PostAttachmentStates,
|
||||||
|
PostStates,
|
||||||
|
TimelineEvent,
|
||||||
|
)
|
||||||
|
from core.files import blurhash_image, resize_image
|
||||||
|
from core.html import html_to_plaintext
|
||||||
|
from core.models import Config
|
||||||
|
from users.decorators import identity_required
|
||||||
|
|
||||||
|
|
||||||
|
@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.local_only, "Local Only"),
|
||||||
|
(Post.Visibilities.unlisted, "Unlisted"),
|
||||||
|
(Post.Visibilities.followers, "Followers & Mentioned Only"),
|
||||||
|
(Post.Visibilities.mentioned, "Mentioned Only"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
content_warning = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label=Config.lazy_system_value("content_warning_text"),
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
"placeholder": Config.lazy_system_value("content_warning_text"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
help_text="Optional - Post will be hidden behind this text until clicked",
|
||||||
|
)
|
||||||
|
reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||||
|
|
||||||
|
def clean_text(self):
|
||||||
|
text = self.cleaned_data.get("text")
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
length = len(text)
|
||||||
|
if length > Config.system.post_length:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f"Maximum post length is {Config.system.post_length} characters (you have {length})"
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
initial = super().get_initial()
|
||||||
|
if self.post_obj:
|
||||||
|
initial.update(
|
||||||
|
{
|
||||||
|
"reply_to": self.reply_to.pk if self.reply_to else "",
|
||||||
|
"visibility": self.post_obj.visibility,
|
||||||
|
"text": html_to_plaintext(self.post_obj.content),
|
||||||
|
"content_warning": self.post_obj.summary,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
initial[
|
||||||
|
"visibility"
|
||||||
|
] = self.request.identity.config_identity.default_post_visibility
|
||||||
|
if self.reply_to:
|
||||||
|
initial["reply_to"] = self.reply_to.pk
|
||||||
|
if self.reply_to.visibility == Post.Visibilities.public:
|
||||||
|
initial["visibility"] = Post.Visibilities.unlisted
|
||||||
|
else:
|
||||||
|
initial["visibility"] = self.reply_to.visibility
|
||||||
|
initial["text"] = f"@{self.reply_to.author.handle} "
|
||||||
|
return initial
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Gather any attachment objects now, they're not in the form proper
|
||||||
|
attachments = []
|
||||||
|
if "attachment" in self.request.POST:
|
||||||
|
attachments = PostAttachment.objects.filter(
|
||||||
|
pk__in=self.request.POST.getlist("attachment", [])
|
||||||
|
)
|
||||||
|
# Dispatch based on edit or not
|
||||||
|
if self.post_obj:
|
||||||
|
self.post_obj.edit_local(
|
||||||
|
content=form.cleaned_data["text"],
|
||||||
|
summary=form.cleaned_data.get("content_warning"),
|
||||||
|
visibility=form.cleaned_data["visibility"],
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
self.post_obj.transition_perform(PostStates.edited)
|
||||||
|
else:
|
||||||
|
post = Post.create_local(
|
||||||
|
author=self.request.identity,
|
||||||
|
content=form.cleaned_data["text"],
|
||||||
|
summary=form.cleaned_data.get("content_warning"),
|
||||||
|
visibility=form.cleaned_data["visibility"],
|
||||||
|
reply_to=self.reply_to,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
# Add their own timeline event for immediate visibility
|
||||||
|
TimelineEvent.add_post(self.request.identity, post)
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
|
||||||
|
self.post_obj = None
|
||||||
|
if handle and post_id:
|
||||||
|
# Make sure the request identity owns the post!
|
||||||
|
if handle != request.identity.handle:
|
||||||
|
raise PermissionDenied("Post author is not requestor")
|
||||||
|
|
||||||
|
self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
|
||||||
|
|
||||||
|
# Grab the reply-to post info now
|
||||||
|
self.reply_to = None
|
||||||
|
reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
|
||||||
|
if reply_to_id:
|
||||||
|
try:
|
||||||
|
self.reply_to = Post.objects.get(pk=reply_to_id)
|
||||||
|
except Post.DoesNotExist:
|
||||||
|
pass
|
||||||
|
# Keep going with normal rendering
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["reply_to"] = self.reply_to
|
||||||
|
if self.post_obj:
|
||||||
|
context["post"] = self.post_obj
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(identity_required, name="dispatch")
|
||||||
|
class ImageUpload(FormView):
|
||||||
|
"""
|
||||||
|
Handles image upload - returns a new input type hidden to embed in
|
||||||
|
the main form that references an orphaned PostAttachment
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "activities/_image_upload.html"
|
||||||
|
|
||||||
|
class form_class(forms.Form):
|
||||||
|
image = forms.ImageField()
|
||||||
|
description = forms.CharField(required=False)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Make a PostAttachment
|
||||||
|
thumbnail_file = resize_image(form.cleaned_data["image"], size=(400, 225))
|
||||||
|
attachment = PostAttachment.objects.create(
|
||||||
|
blurhash=blurhash_image(thumbnail_file),
|
||||||
|
mimetype=form.cleaned_data["image"].image.get_format_mimetype(),
|
||||||
|
width=form.cleaned_data["image"].image.width,
|
||||||
|
height=form.cleaned_data["image"].image.height,
|
||||||
|
name=form.cleaned_data.get("description"),
|
||||||
|
state=PostAttachmentStates.fetched,
|
||||||
|
)
|
||||||
|
attachment.file.save(
|
||||||
|
form.cleaned_data["image"].name,
|
||||||
|
form.cleaned_data["image"],
|
||||||
|
)
|
||||||
|
attachment.thumbnail.save(
|
||||||
|
form.cleaned_data["image"].name,
|
||||||
|
thumbnail_file,
|
||||||
|
)
|
||||||
|
attachment.save()
|
||||||
|
# Return the response, with a hidden input plus a note
|
||||||
|
return render(
|
||||||
|
self.request, "activities/_image_uploaded.html", {"attachment": attachment}
|
||||||
|
)
|
|
@ -1,21 +1,12 @@
|
||||||
from django import forms
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.generic import FormView, TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
from activities.models import (
|
from activities.models import Post, PostInteraction, PostInteractionStates, PostStates
|
||||||
Post,
|
|
||||||
PostInteraction,
|
|
||||||
PostInteractionStates,
|
|
||||||
PostStates,
|
|
||||||
TimelineEvent,
|
|
||||||
)
|
|
||||||
from core.html import html_to_plaintext
|
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from core.models import Config
|
|
||||||
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
|
||||||
|
|
||||||
|
@ -188,130 +179,3 @@ class Delete(TemplateView):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
self.post_obj.transition_perform(PostStates.deleted)
|
self.post_obj.transition_perform(PostStates.deleted)
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(identity_required, name="dispatch")
|
|
||||||
class Compose(FormView):
|
|
||||||
|
|
||||||
template_name = "activities/compose.html"
|
|
||||||
|
|
||||||
class form_class(forms.Form):
|
|
||||||
id = forms.IntegerField(
|
|
||||||
required=False,
|
|
||||||
widget=forms.HiddenInput(),
|
|
||||||
)
|
|
||||||
|
|
||||||
text = forms.CharField(
|
|
||||||
widget=forms.Textarea(
|
|
||||||
attrs={
|
|
||||||
"placeholder": "What's on your mind?",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
visibility = forms.ChoiceField(
|
|
||||||
choices=[
|
|
||||||
(Post.Visibilities.public, "Public"),
|
|
||||||
(Post.Visibilities.local_only, "Local Only"),
|
|
||||||
(Post.Visibilities.unlisted, "Unlisted"),
|
|
||||||
(Post.Visibilities.followers, "Followers & Mentioned Only"),
|
|
||||||
(Post.Visibilities.mentioned, "Mentioned Only"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
content_warning = forms.CharField(
|
|
||||||
required=False,
|
|
||||||
label=Config.lazy_system_value("content_warning_text"),
|
|
||||||
widget=forms.TextInput(
|
|
||||||
attrs={
|
|
||||||
"placeholder": Config.lazy_system_value("content_warning_text"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
help_text="Optional - Post will be hidden behind this text until clicked",
|
|
||||||
)
|
|
||||||
reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
||||||
|
|
||||||
def clean_text(self):
|
|
||||||
text = self.cleaned_data.get("text")
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
length = len(text)
|
|
||||||
if length > Config.system.post_length:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
f"Maximum post length is {Config.system.post_length} characters (you have {length})"
|
|
||||||
)
|
|
||||||
return text
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
initial = super().get_initial()
|
|
||||||
if self.post_obj:
|
|
||||||
initial.update(
|
|
||||||
{
|
|
||||||
"id": self.post_obj.id,
|
|
||||||
"reply_to": self.reply_to.pk if self.reply_to else "",
|
|
||||||
"visibility": self.post_obj.visibility,
|
|
||||||
"text": html_to_plaintext(self.post_obj.content),
|
|
||||||
"content_warning": self.post_obj.summary,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
initial[
|
|
||||||
"visibility"
|
|
||||||
] = self.request.identity.config_identity.default_post_visibility
|
|
||||||
if self.reply_to:
|
|
||||||
initial["reply_to"] = self.reply_to.pk
|
|
||||||
if self.reply_to.visibility == Post.Visibilities.public:
|
|
||||||
initial["visibility"] = Post.Visibilities.unlisted
|
|
||||||
else:
|
|
||||||
initial["visibility"] = self.reply_to.visibility
|
|
||||||
initial["text"] = f"@{self.reply_to.author.handle} "
|
|
||||||
return initial
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
post_id = form.cleaned_data.get("id")
|
|
||||||
if post_id:
|
|
||||||
post = get_object_or_404(self.request.identity.posts, pk=post_id)
|
|
||||||
post.edit_local(
|
|
||||||
content=form.cleaned_data["text"],
|
|
||||||
summary=form.cleaned_data.get("content_warning"),
|
|
||||||
visibility=form.cleaned_data["visibility"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should there be a timeline event for edits?
|
|
||||||
# E.g. "@user edited #123"
|
|
||||||
|
|
||||||
post.transition_perform(PostStates.edited)
|
|
||||||
else:
|
|
||||||
post = Post.create_local(
|
|
||||||
author=self.request.identity,
|
|
||||||
content=form.cleaned_data["text"],
|
|
||||||
summary=form.cleaned_data.get("content_warning"),
|
|
||||||
visibility=form.cleaned_data["visibility"],
|
|
||||||
reply_to=self.reply_to,
|
|
||||||
)
|
|
||||||
# Add their own timeline event for immediate visibility
|
|
||||||
TimelineEvent.add_post(self.request.identity, post)
|
|
||||||
return redirect("/")
|
|
||||||
|
|
||||||
def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
|
|
||||||
self.post_obj = None
|
|
||||||
if handle and post_id:
|
|
||||||
# Make sure the request identity owns the post!
|
|
||||||
if handle != request.identity.handle:
|
|
||||||
raise PermissionDenied("Post author is not requestor")
|
|
||||||
|
|
||||||
self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
|
|
||||||
|
|
||||||
# Grab the reply-to post info now
|
|
||||||
self.reply_to = None
|
|
||||||
reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
|
|
||||||
if reply_to_id:
|
|
||||||
try:
|
|
||||||
self.reply_to = Post.objects.get(pk=reply_to_id)
|
|
||||||
except Post.DoesNotExist:
|
|
||||||
pass
|
|
||||||
# Keep going with normal rendering
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
context["reply_to"] = self.reply_to
|
|
||||||
return context
|
|
||||||
|
|
24
core/files.py
Normal file
24
core/files.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import io
|
||||||
|
|
||||||
|
import blurhash
|
||||||
|
from django.core.files import File
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image(image: File, *, size: tuple[int, int]) -> File:
|
||||||
|
"""
|
||||||
|
Resizes an image to fit insize the given size (cropping one dimension
|
||||||
|
to fit if needed)
|
||||||
|
"""
|
||||||
|
with Image.open(image) as img:
|
||||||
|
resized_image = ImageOps.fit(img, size)
|
||||||
|
new_image_bytes = io.BytesIO()
|
||||||
|
resized_image.save(new_image_bytes, format=img.format)
|
||||||
|
return File(new_image_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def blurhash_image(image) -> str:
|
||||||
|
"""
|
||||||
|
Returns the blurhash for an image
|
||||||
|
"""
|
||||||
|
return blurhash.encode(image, 4, 4)
|
|
@ -17,3 +17,4 @@ sentry-sdk~=1.11.0
|
||||||
dj_database_url~=1.0.0
|
dj_database_url~=1.0.0
|
||||||
python-dotenv~=0.21.0
|
python-dotenv~=0.21.0
|
||||||
email-validator~=1.3.0
|
email-validator~=1.3.0
|
||||||
|
blurhash-python~=1.1.3
|
||||||
|
|
|
@ -586,6 +586,25 @@ form img.preview {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form .uploaded-image {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .uploaded-image img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .uploaded-image p {
|
||||||
|
margin-left: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .uploaded-image .buttons {
|
||||||
|
margin-left: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
form .buttons {
|
form .buttons {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin: -20px 0 15px 0;
|
margin: -20px 0 15px 0;
|
||||||
|
@ -595,6 +614,15 @@ form p+.buttons {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form .button.add-image {
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
form progress {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.right-column form .buttons {
|
.right-column form .buttons {
|
||||||
margin: 5px 10px 5px 0;
|
margin: 5px 10px 5px 0;
|
||||||
}
|
}
|
||||||
|
@ -1062,7 +1090,8 @@ table.metadata td.name {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copied, .copied:hover {
|
.copied,
|
||||||
|
.copied:hover {
|
||||||
color: var(--color-highlight);
|
color: var(--color-highlight);
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.contrib import admin as djadmin
|
||||||
from django.urls import path, re_path
|
from django.urls import path, re_path
|
||||||
from django.views.static import serve
|
from django.views.static import serve
|
||||||
|
|
||||||
from activities.views import explore, posts, search, timelines
|
from activities.views import compose, explore, posts, search, timelines
|
||||||
from core import views as core
|
from core import views as core
|
||||||
from stator import views as stator
|
from stator import views as stator
|
||||||
from users.views import activitypub, admin, auth, follows, identity, settings
|
from users.views import activitypub, admin, auth, follows, identity, settings
|
||||||
|
@ -120,14 +120,19 @@ urlpatterns = [
|
||||||
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
|
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
|
||||||
path("@<handle>/action/", identity.ActionIdentity.as_view()),
|
path("@<handle>/action/", identity.ActionIdentity.as_view()),
|
||||||
# Posts
|
# Posts
|
||||||
path("compose/", posts.Compose.as_view(), name="compose"),
|
path("compose/", compose.Compose.as_view(), name="compose"),
|
||||||
|
path(
|
||||||
|
"compose/image_upload/",
|
||||||
|
compose.ImageUpload.as_view(),
|
||||||
|
name="compose_image_upload",
|
||||||
|
),
|
||||||
path("@<handle>/posts/<int:post_id>/", posts.Individual.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()),
|
||||||
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
|
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
|
||||||
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
|
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
|
||||||
path("@<handle>/posts/<int:post_id>/edit/", posts.Compose.as_view()),
|
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
|
||||||
# Authentication
|
# Authentication
|
||||||
path("auth/login/", auth.Login.as_view(), name="login"),
|
path("auth/login/", auth.Login.as_view(), name="login"),
|
||||||
path("auth/logout/", auth.Logout.as_view(), name="logout"),
|
path("auth/logout/", auth.Logout.as_view(), name="logout"),
|
||||||
|
|
15
templates/activities/_image_upload.html
Normal file
15
templates/activities/_image_upload.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<form
|
||||||
|
hx-encoding='multipart/form-data'
|
||||||
|
hx-post='{% url "compose_image_upload" %}'
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
_="on htmx:xhr:progress(loaded, total)
|
||||||
|
set #attachmentProgress.value to (loaded/total)*100">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include "forms/_field.html" with field=form.image %}
|
||||||
|
{% include "forms/_field.html" with field=form.description %}
|
||||||
|
<div class="buttons">
|
||||||
|
<button _="on click show #attachmentProgress with display:block then hide me">Upload</button>
|
||||||
|
<progress id="attachmentProgress" value="0" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
</form>
|
19
templates/activities/_image_uploaded.html
Normal file
19
templates/activities/_image_uploaded.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<div class="uploaded-image">
|
||||||
|
<input type="hidden" name="attachment" value="{{ attachment.pk }}">
|
||||||
|
<img src="{{ attachment.thumbnail_url }}">
|
||||||
|
<p>
|
||||||
|
{{ attachment.name|default:"(no description)" }}
|
||||||
|
</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<a class="button delete left" _="on click remove closest .uploaded_image">Remove</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if request.htmx %}
|
||||||
|
<a class="button add-image"
|
||||||
|
hx-get='{% url "compose_image_upload" %}'
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
_="on load if beep! length of beep! <.uploaded-image/> > 3 then hide me">
|
||||||
|
Add Image
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
|
@ -73,7 +73,7 @@
|
||||||
<div class="attachments">
|
<div class="attachments">
|
||||||
{% for attachment in post.attachments.all %}
|
{% for attachment in post.attachments.all %}
|
||||||
{% if attachment.is_image %}
|
{% if attachment.is_image %}
|
||||||
<a href="{{ attachment.remote_url }}" class="image"><img src="{{ attachment.remote_url }}" title="{{ attachment.name }}"></a>
|
<a href="{{ attachment.full_url }}" class="image"><img src="{{ attachment.thumbnail_url }}" title="{{ attachment.name }}"></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,8 +17,24 @@
|
||||||
{% include "forms/_field.html" with field=form.content_warning %}
|
{% include "forms/_field.html" with field=form.content_warning %}
|
||||||
{% include "forms/_field.html" with field=form.visibility %}
|
{% include "forms/_field.html" with field=form.visibility %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Images</legend>
|
||||||
|
{% if post %}
|
||||||
|
{% for attachment in post.attachments.all %}
|
||||||
|
{% include "activities/_image_uploaded.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if not post or post.attachments.count < 4 %}
|
||||||
|
<a class="button add-image"
|
||||||
|
hx-get='{% url "compose_image_upload" %}'
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Add Image
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button>{% if form.id.value %}Edit{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
|
<button>{% if post %}Save Edits{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ field }}
|
{{ field }}
|
||||||
</div>
|
</div>
|
||||||
{% if field.field.widget.input_type == "file" %}
|
{% if field.field.widget.input_type == "file" and field.value%}
|
||||||
<img class="preview" src="{{ field.value }}">
|
<img class="preview" src="{{ field.value }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import io
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
from PIL import Image, ImageOps
|
|
||||||
|
|
||||||
|
from core.files import resize_image
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
|
|
||||||
|
|
||||||
|
@ -51,13 +49,6 @@ class ProfilePage(FormView):
|
||||||
"discoverable": identity.discoverable,
|
"discoverable": identity.discoverable,
|
||||||
}
|
}
|
||||||
|
|
||||||
def resize_image(self, image: File, *, size: tuple[int, int]) -> File:
|
|
||||||
with Image.open(image) as img:
|
|
||||||
resized_image = ImageOps.fit(img, size)
|
|
||||||
new_image_bytes = io.BytesIO()
|
|
||||||
resized_image.save(new_image_bytes, format=img.format)
|
|
||||||
return File(new_image_bytes)
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
# Update basic info
|
# Update basic info
|
||||||
identity = self.request.identity
|
identity = self.request.identity
|
||||||
|
@ -70,12 +61,12 @@ class ProfilePage(FormView):
|
||||||
if isinstance(icon, File):
|
if isinstance(icon, File):
|
||||||
identity.icon.save(
|
identity.icon.save(
|
||||||
icon.name,
|
icon.name,
|
||||||
self.resize_image(icon, size=(400, 400)),
|
resize_image(icon, size=(400, 400)),
|
||||||
)
|
)
|
||||||
if isinstance(image, File):
|
if isinstance(image, File):
|
||||||
identity.image.save(
|
identity.image.save(
|
||||||
image.name,
|
image.name,
|
||||||
self.resize_image(image, size=(1500, 500)),
|
resize_image(image, size=(1500, 500)),
|
||||||
)
|
)
|
||||||
identity.save()
|
identity.save()
|
||||||
return redirect(".")
|
return redirect(".")
|
||||||
|
|
Loading…
Reference in a new issue