summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--activities/models/post.py79
-rw-r--r--activities/models/timeline_event.py14
-rw-r--r--activities/views/posts.py32
-rw-r--r--api/schemas.py5
-rw-r--r--api/views/__init__.py4
-rw-r--r--api/views/accounts.py12
-rw-r--r--api/views/filters.py9
-rw-r--r--api/views/media.py76
-rw-r--r--api/views/notifications.py16
-rw-r--r--api/views/search.py9
-rw-r--r--api/views/statuses.py139
-rw-r--r--api/views/timelines.py17
-rw-r--r--core/files.py4
-rw-r--r--users/views/identity.py5
14 files changed, 365 insertions, 56 deletions
diff --git a/activities/models/post.py b/activities/models/post.py
index 16e798c..8c2ce13 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -80,6 +80,12 @@ class PostStates(StateGraph):
class PostQuerySet(models.QuerySet):
+ def not_hidden(self):
+ query = self.exclude(
+ state__in=[PostStates.deleted, PostStates.deleted_fanned_out]
+ )
+ return query
+
def public(self, include_replies: bool = False):
query = self.filter(
visibility__in=[
@@ -103,6 +109,18 @@ class PostQuerySet(models.QuerySet):
return query.filter(in_reply_to__isnull=True)
return query
+ def unlisted(self, include_replies: bool = False):
+ query = self.filter(
+ visibility__in=[
+ Post.Visibilities.public,
+ Post.Visibilities.local_only,
+ Post.Visibilities.unlisted,
+ ],
+ )
+ if not include_replies:
+ return query.filter(in_reply_to__isnull=True)
+ return query
+
def tagged_with(self, hashtag: str | Hashtag):
if isinstance(hashtag, str):
tag_q = models.Q(hashtags__contains=hashtag)
@@ -118,12 +136,18 @@ class PostManager(models.Manager):
def get_queryset(self):
return PostQuerySet(self.model, using=self._db)
+ def not_hidden(self):
+ return self.get_queryset().not_hidden()
+
def public(self, include_replies: bool = False):
return self.get_queryset().public(include_replies=include_replies)
def local_public(self, include_replies: bool = False):
return self.get_queryset().local_public(include_replies=include_replies)
+ def unlisted(self, include_replies: bool = False):
+ return self.get_queryset().unlisted(include_replies=include_replies)
+
def tagged_with(self, hashtag: str | Hashtag):
return self.get_queryset().tagged_with(hashtag=hashtag)
@@ -248,6 +272,8 @@ class Post(StatorModel):
"""
Returns the actual Post object we're replying to, if we can find it
"""
+ if self.in_reply_to is None:
+ return None
return (
Post.objects.filter(object_uri=self.in_reply_to)
.select_related("author")
@@ -338,6 +364,7 @@ class Post(StatorModel):
author: Identity,
content: str,
summary: str | None = None,
+ sensitive: bool = False,
visibility: int = Visibilities.public,
reply_to: Optional["Post"] = None,
attachments: list | None = None,
@@ -359,7 +386,7 @@ class Post(StatorModel):
author=author,
content=content,
summary=summary or None,
- sensitive=bool(summary),
+ sensitive=bool(summary) or sensitive,
local=True,
visibility=visibility,
hashtags=hashtags,
@@ -424,6 +451,48 @@ class Post(StatorModel):
hashtag=hashtag,
)
+ ### Actions ###
+
+ def interact_as(self, identity, type):
+ from activities.models import PostInteraction, PostInteractionStates
+
+ interaction = PostInteraction.objects.get_or_create(
+ type=type, identity=identity, post=self
+ )[0]
+ if interaction.state in [
+ PostInteractionStates.undone,
+ PostInteractionStates.undone_fanned_out,
+ ]:
+ interaction.transition_perform(PostInteractionStates.new)
+
+ def uninteract_as(self, identity, type):
+ from activities.models import PostInteraction, PostInteractionStates
+
+ for interaction in PostInteraction.objects.filter(
+ type=type, identity=identity, post=self
+ ):
+ interaction.transition_perform(PostInteractionStates.undone)
+
+ def like_as(self, identity):
+ from activities.models import PostInteraction
+
+ self.interact_as(identity, PostInteraction.Types.like)
+
+ def unlike_as(self, identity):
+ from activities.models import PostInteraction
+
+ self.uninteract_as(identity, PostInteraction.Types.like)
+
+ def boost_as(self, identity):
+ from activities.models import PostInteraction
+
+ self.interact_as(identity, PostInteraction.Types.boost)
+
+ def unboost_as(self, identity):
+ from activities.models import PostInteraction
+
+ self.uninteract_as(identity, PostInteraction.Types.boost)
+
### ActivityPub (outbound) ###
def to_ap(self) -> dict:
@@ -711,11 +780,11 @@ class Post(StatorModel):
### Mastodon API ###
- def to_mastodon_json(self):
+ def to_mastodon_json(self, interactions=None):
reply_parent = None
if self.in_reply_to:
reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first()
- return {
+ value = {
"id": self.pk,
"uri": self.object_uri,
"created_at": format_ld_date(self.published),
@@ -755,3 +824,7 @@ class Post(StatorModel):
"text": self.safe_content_plain(),
"edited_at": format_ld_date(self.edited) if self.edited else None,
}
+ if interactions:
+ value["favourited"] = self.pk in interactions.get("like", [])
+ value["reblogged"] = self.pk in interactions.get("boost", [])
+ return value
diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py
index 30d473d..16f8632 100644
--- a/activities/models/timeline_event.py
+++ b/activities/models/timeline_event.py
@@ -148,7 +148,7 @@ class TimelineEvent(models.Model):
### Mastodon Client API ###
- def to_mastodon_notification_json(self):
+ def to_mastodon_notification_json(self, interactions=None):
result = {
"id": self.pk,
"created_at": format_ld_date(self.created),
@@ -156,13 +156,19 @@ class TimelineEvent(models.Model):
}
if self.type == self.Types.liked:
result["type"] = "favourite"
- result["status"] = self.subject_post.to_mastodon_json()
+ result["status"] = self.subject_post.to_mastodon_json(
+ interactions=interactions
+ )
elif self.type == self.Types.boosted:
result["type"] = "reblog"
- result["status"] = self.subject_post.to_mastodon_json()
+ result["status"] = self.subject_post.to_mastodon_json(
+ interactions=interactions
+ )
elif self.type == self.Types.mentioned:
result["type"] = "mention"
- result["status"] = self.subject_post.to_mastodon_json()
+ result["status"] = self.subject_post.to_mastodon_json(
+ interactions=interactions
+ )
elif self.type == self.Types.followed:
result["type"] = "follow"
else:
diff --git a/activities/views/posts.py b/activities/views/posts.py
index ccc38fc..e285c7e 100644
--- a/activities/views/posts.py
+++ b/activities/views/posts.py
@@ -6,7 +6,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers
from django.views.generic import TemplateView, View
-from activities.models import Post, PostInteraction, PostInteractionStates, PostStates
+from activities.models import Post, PostInteraction, PostStates
from core.decorators import cache_page_by_ap_json
from core.ld import canonicalise
from users.decorators import identity_required
@@ -94,20 +94,9 @@ class Like(View):
identity.posts.prefetch_related("attachments"), pk=post_id
)
if self.undo:
- # Undo any likes on the post
- for interaction in PostInteraction.objects.filter(
- type=PostInteraction.Types.like,
- identity=request.identity,
- post=post,
- ):
- interaction.transition_perform(PostInteractionStates.undone)
+ post.unlike_as(self.request.identity)
else:
- # Make a like on this post if we didn't already
- PostInteraction.objects.get_or_create(
- type=PostInteraction.Types.like,
- identity=request.identity,
- post=post,
- )
+ post.like_as(self.request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
@@ -133,20 +122,9 @@ class Boost(View):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(identity.posts, pk=post_id)
if self.undo:
- # Undo any boosts on the post
- for interaction in PostInteraction.objects.filter(
- type=PostInteraction.Types.boost,
- identity=request.identity,
- post=post,
- ):
- interaction.transition_perform(PostInteractionStates.undone)
+ post.unboost_as(request.identity)
else:
- # Make a boost on this post if we didn't already
- PostInteraction.objects.get_or_create(
- type=PostInteraction.Types.boost,
- identity=request.identity,
- post=post,
- )
+ post.boost_as(request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
diff --git a/api/schemas.py b/api/schemas.py
index a8f4e45..97b8169 100644
--- a/api/schemas.py
+++ b/api/schemas.py
@@ -160,3 +160,8 @@ class Relationship(Schema):
domain_blocking: bool
endorsed: bool
note: str
+
+
+class Context(Schema):
+ ancestors: list[Status]
+ descendants: list[Status]
diff --git a/api/views/__init__.py b/api/views/__init__.py
index c6dc765..f95fc21 100644
--- a/api/views/__init__.py
+++ b/api/views/__init__.py
@@ -1,8 +1,10 @@
from .accounts import * # noqa
from .apps import * # noqa
-from .base import api_router # noqa
+from .filters import * # noqa
from .instance import * # noqa
+from .media import * # noqa
from .notifications import * # noqa
from .oauth import * # noqa
from .search import * # noqa
+from .statuses import * # noqa
from .timelines import * # noqa
diff --git a/api/views/accounts.py b/api/views/accounts.py
index 1b883e8..43ec75d 100644
--- a/api/views/accounts.py
+++ b/api/views/accounts.py
@@ -1,12 +1,11 @@
from django.shortcuts import get_object_or_404
-from activities.models import Post
+from activities.models import Post, PostInteraction
from api import schemas
+from api.decorators import identity_required
from api.views.base import api_router
from users.models import Identity
-from ..decorators import identity_required
-
@api_router.get("/v1/accounts/verify_credentials", response=schemas.Account)
@identity_required
@@ -69,7 +68,8 @@ def account_statuses(
):
identity = get_object_or_404(Identity, pk=id)
posts = (
- identity.posts.public()
+ identity.posts.not_hidden()
+ .unlisted(include_replies=not exclude_replies)
.select_related("author")
.prefetch_related("attachments")
.order_by("-created")
@@ -91,4 +91,6 @@ def account_statuses(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
posts = posts.filter(created__gt=anchor_post.created).order_by("created")
- return [post.to_mastodon_json() for post in posts[:limit]]
+ posts = list(posts[:limit])
+ interactions = PostInteraction.get_post_interactions(posts, request.identity)
+ return [post.to_mastodon_json(interactions=interactions) for post in posts]
diff --git a/api/views/filters.py b/api/views/filters.py
new file mode 100644
index 0000000..ec82a7d
--- /dev/null
+++ b/api/views/filters.py
@@ -0,0 +1,9 @@
+from api.views.base import api_router
+
+from ..decorators import identity_required
+
+
+@api_router.get("/v1/filters")
+@identity_required
+def status(request):
+ return []
diff --git a/api/views/media.py b/api/views/media.py
new file mode 100644
index 0000000..35c0650
--- /dev/null
+++ b/api/views/media.py
@@ -0,0 +1,76 @@
+from django.shortcuts import get_object_or_404
+from ninja import File, Schema
+from ninja.files import UploadedFile
+
+from activities.models import PostAttachment, PostAttachmentStates
+from api import schemas
+from api.views.base import api_router
+from core.files import blurhash_image, resize_image
+
+from ..decorators import identity_required
+
+
+class UploadMediaSchema(Schema):
+ description: str = ""
+ focus: str = "0,0"
+
+
+@api_router.post("/v1/media", response=schemas.MediaAttachment)
+@api_router.post("/v2/media", response=schemas.MediaAttachment)
+@identity_required
+def upload_media(
+ request,
+ file: UploadedFile = File(...),
+ details: UploadMediaSchema | None = None,
+):
+ main_file = resize_image(
+ file,
+ size=(2000, 2000),
+ cover=False,
+ )
+ thumbnail_file = resize_image(
+ file,
+ size=(400, 225),
+ cover=True,
+ )
+ attachment = PostAttachment.objects.create(
+ blurhash=blurhash_image(thumbnail_file),
+ mimetype="image/webp",
+ width=main_file.image.width,
+ height=main_file.image.height,
+ name=details.description if details else None,
+ state=PostAttachmentStates.fetched,
+ )
+ attachment.file.save(
+ main_file.name,
+ main_file,
+ )
+ attachment.thumbnail.save(
+ thumbnail_file.name,
+ thumbnail_file,
+ )
+ attachment.save()
+ return attachment.to_mastodon_json()
+
+
+@api_router.get("/v1/media/{id}", response=schemas.MediaAttachment)
+@identity_required
+def get_media(
+ request,
+ id: str,
+):
+ attachment = get_object_or_404(PostAttachment, pk=id)
+ return attachment.to_mastodon_json()
+
+
+@api_router.put("/v1/media/{id}", response=schemas.MediaAttachment)
+@identity_required
+def update_media(
+ request,
+ id: str,
+ details: UploadMediaSchema | None = None,
+):
+ attachment = get_object_or_404(PostAttachment, pk=id)
+ attachment.name = details.description if details else None
+ attachment.save()
+ return attachment.to_mastodon_json()
diff --git a/api/views/notifications.py b/api/views/notifications.py
index 9ccda81..7b05d14 100644
--- a/api/views/notifications.py
+++ b/api/views/notifications.py
@@ -1,8 +1,7 @@
-from activities.models import TimelineEvent
-
-from .. import schemas
-from ..decorators import identity_required
-from .base import api_router
+from activities.models import PostInteraction, TimelineEvent
+from api import schemas
+from api.decorators import identity_required
+from api.views.base import api_router
@api_router.get("/v1/notifications", response=list[schemas.Notification])
@@ -49,4 +48,9 @@ def notifications(
# invert the ordering to accomodate
anchor_event = TimelineEvent.objects.get(pk=min_id)
events = events.filter(created__gt=anchor_event.created).order_by("created")
- return [event.to_mastodon_notification_json() for event in events[:limit]]
+ events = list(events[:limit])
+ interactions = PostInteraction.get_event_interactions(events, request.identity)
+ return [
+ event.to_mastodon_notification_json(interactions=interactions)
+ for event in events
+ ]
diff --git a/api/views/search.py b/api/views/search.py
index 7735a65..bd44cd7 100644
--- a/api/views/search.py
+++ b/api/views/search.py
@@ -2,6 +2,7 @@ from typing import Literal
from ninja import Field
+from activities.models import PostInteraction
from activities.search import Searcher
from api import schemas
from api.decorators import identity_required
@@ -38,5 +39,11 @@ def search(
if type is None or type == "hashtag":
result["hashtag"] = [h.to_mastodon_json() for h in search_result["hashtags"]]
if type is None or type == "statuses":
- result["statuses"] = [p.to_mastodon_json() for p in search_result["posts"]]
+ interactions = PostInteraction.get_post_interactions(
+ search_result["posts"], request.identity
+ )
+ result["statuses"] = [
+ p.to_mastodon_json(interactions=interactions)
+ for p in search_result["posts"]
+ ]
return result
diff --git a/api/views/statuses.py b/api/views/statuses.py
new file mode 100644
index 0000000..752ee65
--- /dev/null
+++ b/api/views/statuses.py
@@ -0,0 +1,139 @@
+from typing import Literal
+
+from django.forms import ValidationError
+from django.shortcuts import get_object_or_404
+from ninja import Schema
+
+from activities.models import (
+ Post,
+ PostAttachment,
+ PostInteraction,
+ PostStates,
+ TimelineEvent,
+)
+from api import schemas
+from api.views.base import api_router
+from core.models import Config
+
+from ..decorators import identity_required
+
+
+class PostStatusSchema(Schema):
+ status: str
+ in_reply_to_id: str | None = None
+ sensitive: bool = False
+ spoiler_text: str | None = None
+ visibility: Literal["public", "unlisted", "private", "direct"] = "public"
+ language: str | None = None
+ scheduled_at: str | None = None
+ media_ids: list[str] = []
+
+
+@api_router.post("/v1/statuses", response=schemas.Status)
+@identity_required
+def post_status(request, details: PostStatusSchema):
+ # Check text length
+ if len(details.status) > Config.system.post_length:
+ raise ValidationError("Status is too long")
+ if len(details.status) == 0 and not details.media_ids:
+ raise ValidationError("Status is empty")
+ # Grab attachments
+ attachments = [get_object_or_404(PostAttachment, pk=id) for id in details.media_ids]
+ # Create the Post
+ visibility_map = {
+ "public": Post.Visibilities.public,
+ "unlisted": Post.Visibilities.unlisted,
+ "private": Post.Visibilities.followers,
+ "direct": Post.Visibilities.mentioned,
+ }
+ reply_post = None
+ if details.in_reply_to_id:
+ try:
+ reply_post = Post.objects.get(pk=details.in_reply_to_id)
+ except Post.DoesNotExist:
+ pass
+ post = Post.create_local(
+ author=request.identity,
+ content=details.status,
+ summary=details.spoiler_text,
+ sensitive=details.sensitive,
+ visibility=visibility_map[details.visibility],
+ reply_to=reply_post,
+ attachments=attachments,
+ )
+ # Add their own timeline event for immediate visibility
+ TimelineEvent.add_post(request.identity, post)
+ return post.to_mastodon_json()
+
+
+@api_router.get("/v1/statuses/{id}", response=schemas.Status)
+@identity_required
+def status(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ interactions = PostInteraction.get_post_interactions([post], request.identity)
+ return post.to_mastodon_json(interactions=interactions)
+
+
+@api_router.delete("/v1/statuses/{id}", response=schemas.Status)
+@identity_required
+def delete_status(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ post.transition_perform(PostStates.deleted)
+ TimelineEvent.objects.filter(subject_post=post, identity=request.identity).delete()
+ return post.to_mastodon_json()
+
+
+@api_router.get("/v1/statuses/{id}/context", response=schemas.Context)
+@identity_required
+def status_context(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ parent = post.in_reply_to_post()
+ ancestors = []
+ if parent:
+ ancestors.append(parent)
+ descendants = list(Post.objects.filter(in_reply_to=post.object_uri)[:40])
+ interactions = PostInteraction.get_post_interactions(
+ [post] + ancestors + descendants, request.identity
+ )
+ return {
+ "ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors],
+ "descendants": [
+ p.to_mastodon_json(interactions=interactions) for p in descendants
+ ],
+ }
+
+
+@api_router.post("/v1/statuses/{id}/favourite", response=schemas.Status)
+@identity_required
+def favourite_status(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ post.like_as(request.identity)
+ interactions = PostInteraction.get_post_interactions([post], request.identity)
+ return post.to_mastodon_json(interactions=interactions)
+
+
+@api_router.post("/v1/statuses/{id}/unfavourite", response=schemas.Status)
+@identity_required
+def unfavourite_status(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ post.unlike_as(request.identity)
+ interactions = PostInteraction.get_post_interactions([post], request.identity)
+ return post.to_mastodon_json(interactions=interactions)
+
+
+@api_router.post("/v1/statuses/{id}/reblog", response=schemas.Status)
+@identity_required
+def reblog_status(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ post.boost_as(request.identity)
+ interactions = PostInteraction.get_post_interactions([post], request.identity)
+ return post.to_mastodon_json(interactions=interactions)
+
+
+@api_router.post("/v1/statuses/{id}/unreblog", response=schemas.Status)
+@identity_required
+def unreblog_status(request, id: str):
+ post = get_object_or_404(Post, pk=id)
+ post.unboost_as(request.identity)
+ interactions = PostInteraction.get_post_interactions([post], request.identity)
+ return post.to_mastodon_json(interactions=interactions)
diff --git a/api/views/timelines.py b/api/views/timelines.py
index d560596..84eed7a 100644
--- a/api/views/timelines.py
+++ b/api/views/timelines.py
@@ -1,4 +1,4 @@
-from activities.models import Post, TimelineEvent
+from activities.models import Post, PostInteraction, TimelineEvent
from .. import schemas
from ..decorators import identity_required
@@ -36,7 +36,12 @@ def home(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
events = events.filter(created__gt=anchor_post.created).order_by("created")
- return [event.subject_post.to_mastodon_json() for event in events[:limit]]
+ events = list(events[:limit])
+ interactions = PostInteraction.get_event_interactions(events, request.identity)
+ return [
+ event.subject_post.to_mastodon_json(interactions=interactions)
+ for event in events
+ ]
@api_router.get("/v1/timelines/public", response=list[schemas.Status])
@@ -76,7 +81,9 @@ def public(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
posts = posts.filter(created__gt=anchor_post.created).order_by("created")
- return [post.to_mastodon_json() for post in posts[:limit]]
+ posts = list(posts[:limit])
+ interactions = PostInteraction.get_post_interactions(posts, request.identity)
+ return [post.to_mastodon_json(interactions=interactions) for post in posts]
@api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status])
@@ -115,7 +122,9 @@ def hashtag(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
posts = posts.filter(created__gt=anchor_post.created).order_by("created")
- return [post.to_mastodon_json() for post in posts[:limit]]
+ posts = list(posts[:limit])
+ interactions = PostInteraction.get_post_interactions(posts, request.identity)
+ return [post.to_mastodon_json(interactions=interactions) for post in posts]
@api_router.get("/v1/conversations", response=list[schemas.Status])
diff --git a/core/files.py b/core/files.py
index 4c7729d..a04cef9 100644
--- a/core/files.py
+++ b/core/files.py
@@ -32,8 +32,8 @@ def resize_image(
return file
-def blurhash_image(image) -> str:
+def blurhash_image(file) -> str:
"""
Returns the blurhash for an image
"""
- return blurhash.encode(image, 4, 4)
+ return blurhash.encode(file, 4, 4)
diff --git a/users/views/identity.py b/users/views/identity.py
index fba640c..b68806b 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -62,9 +62,8 @@ class ViewIdentity(ListView):
def get_queryset(self):
return (
- self.identity.posts.filter(
- visibility__in=[Post.Visibilities.public, Post.Visibilities.unlisted],
- )
+ self.identity.posts.not_hidden()
+ .unlisted(include_replies=True)
.select_related("author")
.prefetch_related("attachments")
.order_by("-created")