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 if mention.username
], ],
"tags": ( "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 if self.hashtags
else [] else []
), ),

View File

@ -1,4 +1,50 @@
import dataclasses
import urllib.parse
from django.db import models 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: class MastodonPaginator:
@ -34,6 +80,7 @@ class MastodonPaginator:
queryset = queryset.filter( queryset = queryset.filter(
**{self.sort_attribute + "__lt": getattr(anchor, self.sort_attribute)} **{self.sort_attribute + "__lt": getattr(anchor, self.sort_attribute)}
) )
if since_id: if since_id:
try: try:
anchor = self.anchor_model.objects.get(pk=since_id) anchor = self.anchor_model.objects.get(pk=since_id)
@ -42,9 +89,10 @@ class MastodonPaginator:
queryset = queryset.filter( queryset = queryset.filter(
**{self.sort_attribute + "__gt": getattr(anchor, self.sort_attribute)} **{self.sort_attribute + "__gt": getattr(anchor, self.sort_attribute)}
) )
if min_id: if min_id:
# Min ID requires items _immediately_ newer than specified, so we # Min ID requires items _immediately_ newer than specified, so we
# invert the ordering to accomodate # invert the ordering to accommodate
try: try:
anchor = self.anchor_model.objects.get(pk=min_id) anchor = self.anchor_model.objects.get(pk=min_id)
except self.anchor_model.DoesNotExist: except self.anchor_model.DoesNotExist:
@ -54,4 +102,10 @@ class MastodonPaginator:
).order_by(self.sort_attribute) ).order_by(self.sort_attribute)
else: else:
queryset = queryset.order_by("-" + self.sort_attribute) 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 group: bool
discoverable: bool discoverable: bool
moved: Union[None, bool, "Account"] moved: Union[None, bool, "Account"]
suspended: bool suspended: bool = False
limited: bool limited: bool = False
created_at: str created_at: str
last_status_at: str | None = Field(...) last_status_at: str | None = Field(...)
statuses_count: int statuses_count: int

View File

@ -1,3 +1,4 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Field from ninja import Field
@ -91,7 +92,8 @@ def account(request, id: str):
@api_router.get("/v1/accounts/{id}/statuses", response=list[schemas.Status]) @api_router.get("/v1/accounts/{id}/statuses", response=list[schemas.Status])
@identity_required @identity_required
def account_statuses( def account_statuses(
request, request: HttpRequest,
response: HttpResponse,
id: str, id: str,
exclude_reblogs: bool = False, exclude_reblogs: bool = False,
exclude_replies: bool = False, exclude_replies: bool = False,
@ -119,16 +121,37 @@ def account_statuses(
queryset = queryset.filter(attachments__pk__isnull=False) queryset = queryset.filter(attachments__pk__isnull=False)
if tagged: if tagged:
queryset = queryset.tagged_with(tagged) queryset = queryset.tagged_with(tagged)
paginator = MastodonPaginator(Post) paginator = MastodonPaginator(Post)
posts = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
since_id=since_id, since_id=since_id,
limit=limit, 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) @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.models import PostInteraction, TimelineEvent
from activities.services import TimelineService from activities.services import TimelineService
from api import schemas from api import schemas
@ -9,7 +11,8 @@ from api.views.base import api_router
@api_router.get("/v1/notifications", response=list[schemas.Notification]) @api_router.get("/v1/notifications", response=list[schemas.Notification])
@identity_required @identity_required
def notifications( def notifications(
request, request: HttpRequest,
response: HttpResponse,
max_id: str | None = None, max_id: str | None = None,
since_id: str | None = None, since_id: str | None = None,
min_id: str | None = None, min_id: str | None = None,
@ -33,15 +36,27 @@ def notifications(
[base_types[r] for r in requested_types] [base_types[r] for r in requested_types]
) )
paginator = MastodonPaginator(TimelineEvent) paginator = MastodonPaginator(TimelineEvent)
events = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
since_id=since_id, since_id=since_id,
limit=limit, 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 [ return [
event.to_mastodon_notification_json(interactions=interactions) 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.models import Post, PostInteraction
from activities.services import TimelineService from activities.services import TimelineService
from api import schemas from api import schemas
from api.decorators import identity_required from api.decorators import identity_required
from api.pagination import MastodonPaginator from api.pagination import MastodonPaginator
from api.views.base import api_router from api.views.base import api_router
from core.models import Config
@api_router.get("/v1/timelines/home", response=list[schemas.Status]) @api_router.get("/v1/timelines/home", response=list[schemas.Status])
@identity_required @identity_required
def home( def home(
request, request: HttpRequest,
response: HttpResponse,
max_id: str | None = None, max_id: str | None = None,
since_id: str | None = None, since_id: str | None = None,
min_id: str | None = None, min_id: str | None = None,
@ -17,24 +21,35 @@ def home(
): ):
paginator = MastodonPaginator(Post) paginator = MastodonPaginator(Post)
queryset = TimelineService(request.identity).home() queryset = TimelineService(request.identity).home()
events = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
since_id=since_id, since_id=since_id,
limit=limit, 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 [ return [
event.subject_post.to_mastodon_json(interactions=interactions) 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]) @api_router.get("/v1/timelines/public", response=list[schemas.Status])
@identity_required
def public( def public(
request, request: HttpRequest,
response: HttpResponse,
local: bool = False, local: bool = False,
remote: bool = False, remote: bool = False,
only_media: bool = False, only_media: bool = False,
@ -43,6 +58,9 @@ def public(
min_id: str | None = None, min_id: str | None = None,
limit: int = 20, limit: int = 20,
): ):
if not request.identity and not Config.system.public_timeline:
return JsonResponse({"error": "public timeline is disabled"}, status=422)
if local: if local:
queryset = TimelineService(request.identity).local() queryset = TimelineService(request.identity).local()
else: else:
@ -52,21 +70,34 @@ def public(
if only_media: if only_media:
queryset = queryset.filter(attachments__id__isnull=True) queryset = queryset.filter(attachments__id__isnull=True)
paginator = MastodonPaginator(Post) paginator = MastodonPaginator(Post)
posts = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
since_id=since_id, since_id=since_id,
limit=limit, 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]) @api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status])
@identity_required @identity_required
def hashtag( def hashtag(
request, request: HttpRequest,
response: HttpResponse,
hashtag: str, hashtag: str,
local: bool = False, local: bool = False,
only_media: bool = False, only_media: bool = False,
@ -83,21 +114,34 @@ def hashtag(
if only_media: if only_media:
queryset = queryset.filter(attachments__id__isnull=True) queryset = queryset.filter(attachments__id__isnull=True)
paginator = MastodonPaginator(Post) paginator = MastodonPaginator(Post)
posts = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
since_id=since_id, since_id=since_id,
limit=limit, 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]) @api_router.get("/v1/conversations", response=list[schemas.Status])
@identity_required @identity_required
def conversations( def conversations(
request, request: HttpRequest,
response: HttpResponse,
max_id: str | None = None, max_id: str | None = None,
since_id: str | None = None, since_id: str | None = None,
min_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_TZ_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" 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: 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: 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_per_user: int = 5
identity_max_age: int = 24 * 60 * 60 identity_max_age: int = 24 * 60 * 60
inbox_message_purge_after: int = 24 * 60 * 60 inbox_message_purge_after: int = 24 * 60 * 60
public_timeline: bool = True
hashtag_unreviewed_are_public: bool = True hashtag_unreviewed_are_public: bool = True
hashtag_stats_max_age: int = 60 * 60 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 from activities.models import Emoji
header_image = self.local_image_url() header_image = self.local_image_url()
missing = StaticAbsoluteUrl("img/missing.png").absolute
metadata_value_text = ( metadata_value_text = (
" ".join([m["value"] for m in self.metadata]) if self.metadata else "" " ".join([m["value"] for m in self.metadata]) if self.metadata else ""
) )
@ -810,14 +812,14 @@ class Identity(StatorModel):
return { return {
"id": self.pk, "id": self.pk,
"username": self.username or "", "username": self.username or "",
"acct": self.username if self.local else self.handle, "acct": self.handle,
"url": self.absolute_profile_uri() or "", "url": self.absolute_profile_uri() or "",
"display_name": self.name or "", "display_name": self.name or "",
"note": self.summary or "", "note": self.summary or "",
"avatar": self.local_icon_url().absolute, "avatar": self.local_icon_url().absolute,
"avatar_static": self.local_icon_url().absolute, "avatar_static": self.local_icon_url().absolute,
"header": 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 None, "header_static": header_image.absolute if header_image else missing,
"locked": False, "locked": False,
"fields": ( "fields": (
[ [

View File

@ -51,10 +51,6 @@ class BasicSettings(AdminSettingsPage):
"help_text": "Displayed on the homepage and the about page.\nUse Markdown for formatting.", "help_text": "Displayed on the homepage and the about page.\nUse Markdown for formatting.",
"display": "textarea", "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": { "site_icon": {
"title": "Site Icon", "title": "Site Icon",
"help_text": "Minimum size 64x64px. Should be square.", "help_text": "Minimum size 64x64px. Should be square.",
@ -93,13 +89,20 @@ class BasicSettings(AdminSettingsPage):
"title": "Unreviewed Emoji Are Public", "title": "Unreviewed Emoji Are Public",
"help_text": "Public Emoji may appear as images, instead of shortcodes", "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 = { layout = {
"Branding": [ "Branding": [
"site_name", "site_name",
"site_about", "site_about",
"site_frontpage_posts",
"site_icon", "site_icon",
"site_banner", "site_banner",
"highlight_color", "highlight_color",
@ -115,6 +118,10 @@ class BasicSettings(AdminSettingsPage):
"hashtag_unreviewed_are_public", "hashtag_unreviewed_are_public",
"emoji_unreviewed_are_public", "emoji_unreviewed_are_public",
], ],
"Timelines": [
"public_timeline",
"site_frontpage_posts",
],
"Identities": [ "Identities": [
"identity_max_per_user", "identity_max_per_user",
"identity_min_length", "identity_min_length",