diff --git a/api/pagination.py b/api/pagination.py index bd2ef46..d7e0324 100644 --- a/api/pagination.py +++ b/api/pagination.py @@ -216,57 +216,45 @@ class MastodonPaginator: max_id: str | None, since_id: str | None, limit: int | None, + home: bool = False, ) -> PaginationResult[TM]: + limit = min(limit or self.default_limit, self.max_limit) + filters = {} + id_field = "id" + reverse = False + if home: + # The home timeline interleaves Post IDs and PostInteraction IDs in an + # annotated field called "subject_id". + id_field = "subject_id" + queryset = queryset.annotate( + subject_id=Case( + When(type=TimelineEvent.Types.post, then=F("subject_post_id")), + default=F("subject_post_interaction"), + ) + ) + # These "does not start with interaction" checks can be removed after a # couple months, when clients have flushed them out. if max_id and not max_id.startswith("interaction"): - queryset = queryset.filter(id__lt=max_id) + filters[f"{id_field}__lt"] = max_id if since_id and not since_id.startswith("interaction"): - queryset = queryset.filter(id__gt=since_id) + filters[f"{id_field}__gt"] = since_id if min_id and not min_id.startswith("interaction"): # Min ID requires items _immediately_ newer than specified, so we # invert the ordering to accommodate - queryset = queryset.filter(id__gt=min_id).order_by("id") - else: - queryset = queryset.order_by("-id") + filters[f"{id_field}__gt"] = min_id + reverse = True + + # Default is to order by ID descending (newest first), except for min_id + # queries, which should order by ID for limiting, then reverse the results to be + # consistent. The clearest explanation of this I've found so far is this: + # https://mastodon.social/@Gargron/100846335353411164 + ordering = id_field if reverse else f"-{id_field}" + results = list(queryset.filter(**filters).order_by(ordering)[:limit]) + if reverse: + results.reverse() - limit = min(limit or self.default_limit, self.max_limit) return PaginationResult( - results=list(queryset[:limit]), - limit=limit, - ) - - def paginate_home( - self, - queryset, - min_id: str | None, - max_id: str | None, - since_id: str | None, - limit: int | None, - ) -> PaginationResult: - """ - The home timeline requires special handling where we mix Posts and - PostInteractions together. - """ - queryset = queryset.annotate( - event_id=Case( - When(type=TimelineEvent.Types.post, then=F("subject_post_id")), - default=F("subject_post_interaction"), - ) - ) - if max_id and not max_id.startswith("interaction"): - queryset = queryset.filter(event_id__lt=max_id) - if since_id and not since_id.startswith("interaction"): - queryset = queryset.filter(event_id__gt=since_id) - if min_id and not min_id.startswith("interaction"): - # Min ID requires items _immediately_ newer than specified, so we - # invert the ordering to accommodate - queryset = queryset.filter(event_id__gt=min_id).order_by("event_id") - else: - queryset = queryset.order_by("-event_id") - - limit = min(limit or self.default_limit, self.max_limit) - return PaginationResult( - results=list(queryset[:limit]), + results=results, limit=limit, ) diff --git a/api/views/timelines.py b/api/views/timelines.py index c254303..d8101cf 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,7 +1,7 @@ from django.http import HttpRequest from hatchway import ApiError, ApiResponse, api_view -from activities.models import Post +from activities.models import Post, TimelineEvent from activities.services import TimelineService from api import schemas from api.decorators import scope_required @@ -34,12 +34,13 @@ def home( "subject_post_interaction__post__mentions__domain", "subject_post_interaction__post__author__posts", ) - pager = paginator.paginate_home( + pager: PaginationResult[TimelineEvent] = paginator.paginate( queryset, min_id=min_id, max_id=max_id, since_id=since_id, limit=limit, + home=True, ) return PaginatingApiResponse( schemas.Status.map_from_timeline_event(pager.results, request.identity),