Fixes for the Toot! client (#248) (#275)

* 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:
Tyler Kennedy 2022-12-29 12:31:32 -05:00 committed by GitHub
parent b03d9f0e12
commit cc7824394b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 192 additions and 37 deletions

View File

@ -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 []
),

View File

@ -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,
)

View File

@ -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

View File

@ -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)

View File

@ -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
]

View File

@ -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,

View File

@ -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:

View File

@ -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

BIN
static/img/missing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 B

View File

@ -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": (
[

View File

@ -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",