* Various Toot! fixes * Use a fallback 1x1 pixel image for missing headers, same as Mastodon. The header and header_static are _not_ optional nor nullable according to the spec. * Try removing some fields which probably shouldn't be set. * Pagination with next/prev.
This commit is contained in:
parent
b03d9f0e12
commit
cc7824394b
|
@ -900,7 +900,13 @@ class Post(StatorModel):
|
|||
if mention.username
|
||||
],
|
||||
"tags": (
|
||||
[{"name": tag, "url": "/tag/{tag}/"} for tag in self.hashtags]
|
||||
[
|
||||
{
|
||||
"name": tag,
|
||||
"url": f"https://{self.author.domain.uri_domain}/tags/{tag}/",
|
||||
}
|
||||
for tag in self.hashtags
|
||||
]
|
||||
if self.hashtags
|
||||
else []
|
||||
),
|
||||
|
|
|
@ -1,4 +1,50 @@
|
|||
import dataclasses
|
||||
import urllib.parse
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PaginationResult:
|
||||
#: A list of objects that matched the pagination query.
|
||||
results: list[models.Model]
|
||||
#: The actual applied limit, which may be different from what was requested.
|
||||
limit: int
|
||||
sort_attribute: str
|
||||
|
||||
def next(self, request: HttpRequest, allowed_params: list[str]):
|
||||
"""
|
||||
Returns a URL to the next page of results.
|
||||
"""
|
||||
if not self.results:
|
||||
return None
|
||||
|
||||
params = self.filter_params(request, allowed_params)
|
||||
params["max_id"] = self.results[-1].pk
|
||||
|
||||
return f"{request.build_absolute_uri(request.path)}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def prev(self, request: HttpRequest, allowed_params: list[str]):
|
||||
"""
|
||||
Returns a URL to the previous page of results.
|
||||
"""
|
||||
if not self.results:
|
||||
return None
|
||||
|
||||
params = self.filter_params(request, allowed_params)
|
||||
params["min_id"] = self.results[0].pk
|
||||
|
||||
return f"{request.build_absolute_uri(request.path)}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
@staticmethod
|
||||
def filter_params(request: HttpRequest, allowed_params: list[str]):
|
||||
params = {}
|
||||
for key in allowed_params:
|
||||
value = request.GET.get(key, None)
|
||||
if value:
|
||||
params[key] = value
|
||||
return params
|
||||
|
||||
|
||||
class MastodonPaginator:
|
||||
|
@ -34,6 +80,7 @@ class MastodonPaginator:
|
|||
queryset = queryset.filter(
|
||||
**{self.sort_attribute + "__lt": getattr(anchor, self.sort_attribute)}
|
||||
)
|
||||
|
||||
if since_id:
|
||||
try:
|
||||
anchor = self.anchor_model.objects.get(pk=since_id)
|
||||
|
@ -42,9 +89,10 @@ class MastodonPaginator:
|
|||
queryset = queryset.filter(
|
||||
**{self.sort_attribute + "__gt": getattr(anchor, self.sort_attribute)}
|
||||
)
|
||||
|
||||
if min_id:
|
||||
# Min ID requires items _immediately_ newer than specified, so we
|
||||
# invert the ordering to accomodate
|
||||
# invert the ordering to accommodate
|
||||
try:
|
||||
anchor = self.anchor_model.objects.get(pk=min_id)
|
||||
except self.anchor_model.DoesNotExist:
|
||||
|
@ -54,4 +102,10 @@ class MastodonPaginator:
|
|||
).order_by(self.sort_attribute)
|
||||
else:
|
||||
queryset = queryset.order_by("-" + self.sort_attribute)
|
||||
return list(queryset[: min(limit or self.default_limit, self.max_limit)])
|
||||
|
||||
limit = min(limit or self.default_limit, self.max_limit)
|
||||
return PaginationResult(
|
||||
results=list(queryset[:limit]),
|
||||
limit=limit,
|
||||
sort_attribute=self.sort_attribute,
|
||||
)
|
||||
|
|
|
@ -44,8 +44,8 @@ class Account(Schema):
|
|||
group: bool
|
||||
discoverable: bool
|
||||
moved: Union[None, bool, "Account"]
|
||||
suspended: bool
|
||||
limited: bool
|
||||
suspended: bool = False
|
||||
limited: bool = False
|
||||
created_at: str
|
||||
last_status_at: str | None = Field(...)
|
||||
statuses_count: int
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Field
|
||||
|
||||
|
@ -91,7 +92,8 @@ def account(request, id: str):
|
|||
@api_router.get("/v1/accounts/{id}/statuses", response=list[schemas.Status])
|
||||
@identity_required
|
||||
def account_statuses(
|
||||
request,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
id: str,
|
||||
exclude_reblogs: bool = False,
|
||||
exclude_replies: bool = False,
|
||||
|
@ -119,16 +121,37 @@ def account_statuses(
|
|||
queryset = queryset.filter(attachments__pk__isnull=False)
|
||||
if tagged:
|
||||
queryset = queryset.tagged_with(tagged)
|
||||
|
||||
paginator = MastodonPaginator(Post)
|
||||
posts = paginator.paginate(
|
||||
pager = paginator.paginate(
|
||||
queryset,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
since_id=since_id,
|
||||
limit=limit,
|
||||
)
|
||||
interactions = PostInteraction.get_post_interactions(posts, request.identity)
|
||||
return [post.to_mastodon_json(interactions=interactions) for post in queryset]
|
||||
|
||||
if pager.results:
|
||||
params = [
|
||||
"limit",
|
||||
"id",
|
||||
"exclude_reblogs",
|
||||
"exclude_replies",
|
||||
"only_media",
|
||||
"pinned",
|
||||
"tagged",
|
||||
]
|
||||
response.headers["Link"] = ", ".join(
|
||||
(
|
||||
f'<{pager.next(request, params)}>; rel="next"',
|
||||
f'<{pager.prev(request, params)}>; rel="prev"',
|
||||
)
|
||||
)
|
||||
|
||||
interactions = PostInteraction.get_post_interactions(
|
||||
pager.results, request.identity
|
||||
)
|
||||
return [post.to_mastodon_json(interactions=interactions) for post in pager.results]
|
||||
|
||||
|
||||
@api_router.post("/v1/accounts/{id}/follow", response=schemas.Relationship)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from activities.models import PostInteraction, TimelineEvent
|
||||
from activities.services import TimelineService
|
||||
from api import schemas
|
||||
|
@ -9,7 +11,8 @@ from api.views.base import api_router
|
|||
@api_router.get("/v1/notifications", response=list[schemas.Notification])
|
||||
@identity_required
|
||||
def notifications(
|
||||
request,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
max_id: str | None = None,
|
||||
since_id: str | None = None,
|
||||
min_id: str | None = None,
|
||||
|
@ -33,15 +36,27 @@ def notifications(
|
|||
[base_types[r] for r in requested_types]
|
||||
)
|
||||
paginator = MastodonPaginator(TimelineEvent)
|
||||
events = paginator.paginate(
|
||||
pager = paginator.paginate(
|
||||
queryset,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
since_id=since_id,
|
||||
limit=limit,
|
||||
)
|
||||
interactions = PostInteraction.get_event_interactions(events, request.identity)
|
||||
|
||||
if pager.results:
|
||||
params = ["limit", "account_id"]
|
||||
response.headers["Link"] = ", ".join(
|
||||
(
|
||||
f'<{pager.next(request, params)}>; rel="next"',
|
||||
f'<{pager.prev(request, params)}>; rel="prev"',
|
||||
)
|
||||
)
|
||||
|
||||
interactions = PostInteraction.get_event_interactions(
|
||||
pager.results, request.identity
|
||||
)
|
||||
return [
|
||||
event.to_mastodon_notification_json(interactions=interactions)
|
||||
for event in events
|
||||
for event in pager.results
|
||||
]
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
|
||||
from activities.models import Post, PostInteraction
|
||||
from activities.services import TimelineService
|
||||
from api import schemas
|
||||
from api.decorators import identity_required
|
||||
from api.pagination import MastodonPaginator
|
||||
from api.views.base import api_router
|
||||
from core.models import Config
|
||||
|
||||
|
||||
@api_router.get("/v1/timelines/home", response=list[schemas.Status])
|
||||
@identity_required
|
||||
def home(
|
||||
request,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
max_id: str | None = None,
|
||||
since_id: str | None = None,
|
||||
min_id: str | None = None,
|
||||
|
@ -17,24 +21,35 @@ def home(
|
|||
):
|
||||
paginator = MastodonPaginator(Post)
|
||||
queryset = TimelineService(request.identity).home()
|
||||
events = paginator.paginate(
|
||||
pager = paginator.paginate(
|
||||
queryset,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
since_id=since_id,
|
||||
limit=limit,
|
||||
)
|
||||
interactions = PostInteraction.get_event_interactions(events, request.identity)
|
||||
interactions = PostInteraction.get_event_interactions(
|
||||
pager.results, request.identity
|
||||
)
|
||||
|
||||
if pager.results:
|
||||
response.headers["Link"] = ", ".join(
|
||||
(
|
||||
f"<{pager.next(request, ['limit'])}>; rel=\"next\"",
|
||||
f"<{pager.prev(request, ['limit'])}>; rel=\"prev\"",
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
event.subject_post.to_mastodon_json(interactions=interactions)
|
||||
for event in events
|
||||
for event in pager.results
|
||||
]
|
||||
|
||||
|
||||
@api_router.get("/v1/timelines/public", response=list[schemas.Status])
|
||||
@identity_required
|
||||
def public(
|
||||
request,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
local: bool = False,
|
||||
remote: bool = False,
|
||||
only_media: bool = False,
|
||||
|
@ -43,6 +58,9 @@ def public(
|
|||
min_id: str | None = None,
|
||||
limit: int = 20,
|
||||
):
|
||||
if not request.identity and not Config.system.public_timeline:
|
||||
return JsonResponse({"error": "public timeline is disabled"}, status=422)
|
||||
|
||||
if local:
|
||||
queryset = TimelineService(request.identity).local()
|
||||
else:
|
||||
|
@ -52,21 +70,34 @@ def public(
|
|||
if only_media:
|
||||
queryset = queryset.filter(attachments__id__isnull=True)
|
||||
paginator = MastodonPaginator(Post)
|
||||
posts = paginator.paginate(
|
||||
pager = paginator.paginate(
|
||||
queryset,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
since_id=since_id,
|
||||
limit=limit,
|
||||
)
|
||||
interactions = PostInteraction.get_post_interactions(posts, request.identity)
|
||||
return [post.to_mastodon_json(interactions=interactions) for post in posts]
|
||||
|
||||
if pager.results:
|
||||
params = ["limit", "local", "remote", "only_media"]
|
||||
response.headers["Link"] = ", ".join(
|
||||
(
|
||||
f'<{pager.next(request, params)}>; rel="next"',
|
||||
f'<{pager.prev(request, params)}>; rel="prev"',
|
||||
)
|
||||
)
|
||||
|
||||
interactions = PostInteraction.get_post_interactions(
|
||||
pager.results, request.identity
|
||||
)
|
||||
return [post.to_mastodon_json(interactions=interactions) for post in pager.results]
|
||||
|
||||
|
||||
@api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status])
|
||||
@identity_required
|
||||
def hashtag(
|
||||
request,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
hashtag: str,
|
||||
local: bool = False,
|
||||
only_media: bool = False,
|
||||
|
@ -83,21 +114,34 @@ def hashtag(
|
|||
if only_media:
|
||||
queryset = queryset.filter(attachments__id__isnull=True)
|
||||
paginator = MastodonPaginator(Post)
|
||||
posts = paginator.paginate(
|
||||
pager = paginator.paginate(
|
||||
queryset,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
since_id=since_id,
|
||||
limit=limit,
|
||||
)
|
||||
interactions = PostInteraction.get_post_interactions(posts, request.identity)
|
||||
return [post.to_mastodon_json(interactions=interactions) for post in posts]
|
||||
|
||||
if pager.results:
|
||||
params = ["limit", "local", "hashtag", "only_media"]
|
||||
response.headers["Link"] = ", ".join(
|
||||
(
|
||||
f'<{pager.next(request, params)}>; rel="next"',
|
||||
f'<{pager.prev(request, params)}>; rel="prev"',
|
||||
)
|
||||
)
|
||||
|
||||
interactions = PostInteraction.get_post_interactions(
|
||||
pager.results, request.identity
|
||||
)
|
||||
return [post.to_mastodon_json(interactions=interactions) for post in pager.results]
|
||||
|
||||
|
||||
@api_router.get("/v1/conversations", response=list[schemas.Status])
|
||||
@identity_required
|
||||
def conversations(
|
||||
request,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
max_id: str | None = None,
|
||||
since_id: str | None = None,
|
||||
min_id: str | None = None,
|
||||
|
|
|
@ -408,7 +408,7 @@ schemas = {
|
|||
},
|
||||
}
|
||||
|
||||
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
||||
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.Z"
|
||||
DATETIME_TZ_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
|
||||
|
@ -497,7 +497,10 @@ def get_str_or_id(value: str | dict | None) -> str | None:
|
|||
|
||||
|
||||
def format_ld_date(value: datetime.datetime) -> str:
|
||||
return value.strftime(DATETIME_FORMAT)
|
||||
# We chop the timestamp to be identical to the timestamps returned by
|
||||
# Mastodon's API, because some clients like Toot! (for iOS) are especially
|
||||
# picky about timestamp parsing.
|
||||
return f"{value.strftime(DATETIME_MS_FORMAT)[:-4]}Z"
|
||||
|
||||
|
||||
def parse_ld_date(value: str | None) -> datetime.datetime | None:
|
||||
|
|
|
@ -221,6 +221,7 @@ class Config(models.Model):
|
|||
identity_max_per_user: int = 5
|
||||
identity_max_age: int = 24 * 60 * 60
|
||||
inbox_message_purge_after: int = 24 * 60 * 60
|
||||
public_timeline: bool = True
|
||||
|
||||
hashtag_unreviewed_are_public: bool = True
|
||||
hashtag_stats_max_age: int = 60 * 60
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 81 B |
|
@ -801,6 +801,8 @@ class Identity(StatorModel):
|
|||
from activities.models import Emoji
|
||||
|
||||
header_image = self.local_image_url()
|
||||
missing = StaticAbsoluteUrl("img/missing.png").absolute
|
||||
|
||||
metadata_value_text = (
|
||||
" ".join([m["value"] for m in self.metadata]) if self.metadata else ""
|
||||
)
|
||||
|
@ -810,14 +812,14 @@ class Identity(StatorModel):
|
|||
return {
|
||||
"id": self.pk,
|
||||
"username": self.username or "",
|
||||
"acct": self.username if self.local else self.handle,
|
||||
"acct": self.handle,
|
||||
"url": self.absolute_profile_uri() or "",
|
||||
"display_name": self.name or "",
|
||||
"note": self.summary or "",
|
||||
"avatar": self.local_icon_url().absolute,
|
||||
"avatar_static": self.local_icon_url().absolute,
|
||||
"header": header_image.absolute if header_image else None,
|
||||
"header_static": header_image.absolute if header_image else None,
|
||||
"header": header_image.absolute if header_image else missing,
|
||||
"header_static": header_image.absolute if header_image else missing,
|
||||
"locked": False,
|
||||
"fields": (
|
||||
[
|
||||
|
|
|
@ -51,10 +51,6 @@ class BasicSettings(AdminSettingsPage):
|
|||
"help_text": "Displayed on the homepage and the about page.\nUse Markdown for formatting.",
|
||||
"display": "textarea",
|
||||
},
|
||||
"site_frontpage_posts": {
|
||||
"title": "Show Posts On Front Page",
|
||||
"help_text": "Whether to show some recent posts on the logged-out homepage.",
|
||||
},
|
||||
"site_icon": {
|
||||
"title": "Site Icon",
|
||||
"help_text": "Minimum size 64x64px. Should be square.",
|
||||
|
@ -93,13 +89,20 @@ class BasicSettings(AdminSettingsPage):
|
|||
"title": "Unreviewed Emoji Are Public",
|
||||
"help_text": "Public Emoji may appear as images, instead of shortcodes",
|
||||
},
|
||||
"public_timeline": {
|
||||
"title": "Public Timeline",
|
||||
"help_text": "If enabled, allows anonymous access to the public timeline",
|
||||
},
|
||||
"site_frontpage_posts": {
|
||||
"title": "Show Public Timeline On Front Page",
|
||||
"help_text": "Whether to show some recent posts on the logged-out homepage",
|
||||
},
|
||||
}
|
||||
|
||||
layout = {
|
||||
"Branding": [
|
||||
"site_name",
|
||||
"site_about",
|
||||
"site_frontpage_posts",
|
||||
"site_icon",
|
||||
"site_banner",
|
||||
"highlight_color",
|
||||
|
@ -115,6 +118,10 @@ class BasicSettings(AdminSettingsPage):
|
|||
"hashtag_unreviewed_are_public",
|
||||
"emoji_unreviewed_are_public",
|
||||
],
|
||||
"Timelines": [
|
||||
"public_timeline",
|
||||
"site_frontpage_posts",
|
||||
],
|
||||
"Identities": [
|
||||
"identity_max_per_user",
|
||||
"identity_min_length",
|
||||
|
|
Loading…
Reference in New Issue