Fix following past 48 entries and make the e621 page size configurable

Follow lists were paginated at the API default of 48, but the SPA treats
them as complete sets: the tag/pool toggles read their state from page one
(so the 49th follow looked unfollowed and its spinner waited for a page that
could never contain it) and the Followed page rendered only 48 cards while
showing that as the count. Both follow endpoints are now unpaginated — they
are per-user sets and still restricted to the caller's rows — and the three
consumers take plain arrays.

Post visibility: the old J621-Django online view fetched limit=320 (e621's
maximum) while ours hard-coded 48, and fetchPostsByIds capped id batches at
100. The Online browser now has a 'Posts per page' setting (48/100/200/320)
in its sidebar, mirrored in Account -> Browsing preferences, stored per user
as e621_per_page and also used for pool loading; the id-batch cap is raised
to 320.

Tests: follow list shape/isolation (4) and preference validation/merge (3)
added; the full backend suite is 46 green. Live-checked the array response
shape and the preference bounds (200 accepted, 500 rejected).
This commit is contained in:
2026-09-18 19:06:18 -05:00
parent d16a77907a
commit 1adb761c8d
16 changed files with 237 additions and 25 deletions
+3 -1
View File
@@ -145,7 +145,9 @@ Files now stage first and are resolved before entering the library.
- [x] Profile pictures: staff Users page and a self-service Account picker
(searchable library grid, remove supported) set avatars from library J-IDs
- [x] Profile extras: per-user landing page, default rating filter/sort, items
per page and thumbnail size (synced to the account, applied on load)
per page and thumbnail size (synced to the account, applied on load),
plus e621 posts per page (48/100/200/320) for the Online browser and
pool loading
- [x] Backend-less local mode: with no backend connected the SPA runs on the
e621-facing pages only (Online, Pools) using credentials stored in the
browser, and the shell offers a "Setup Backend" button instead
+2
View File
@@ -134,5 +134,7 @@ class PreferencesSerializer(serializers.Serializer):
ordering = serializers.CharField(max_length=30, required=False)
per_page = serializers.IntegerField(min_value=12, max_value=200, required=False)
zoom = serializers.IntegerField(min_value=120, max_value=400, required=False)
# e621 renders at most 320 posts per request.
e621_per_page = serializers.IntegerField(min_value=12, max_value=320, required=False)
# Open Online with the order:hot metatag when no search is given.
online_hot_default = serializers.BooleanField(required=False)
@@ -0,0 +1,49 @@
"""Per-user browse preferences: validation and merge semantics."""
import json
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from rest_framework.authtoken.models import Token
User = get_user_model()
class PreferenceTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="prefs-user", password="prefs-pass-123456"
)
self.client = Client()
self.client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=self.user).key}"
)
def post(self, payload):
return self.client.post(
"/api/auth/preferences/",
data=json.dumps(payload),
content_type="application/json",
)
def test_e621_per_page_round_trips_within_range(self):
response = self.post({"e621_per_page": 320})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["e621_per_page"], 320)
self.assertEqual(
self.client.get("/api/auth/me/").json()["preferences"]["e621_per_page"],
320,
)
def test_e621_per_page_rejects_out_of_range(self):
for value in (0, 8, 321, 1000):
self.assertEqual(self.post({"e621_per_page": value}).status_code, 400, value)
def test_merge_keeps_other_keys(self):
self.post({"ratings": ["s"], "e621_per_page": 100})
response = self.post({"per_page": 24})
data = response.json()
self.assertEqual(data["ratings"], ["s"])
self.assertEqual(data["e621_per_page"], 100)
self.assertEqual(data["per_page"], 24)
@@ -0,0 +1,78 @@
"""The follow lists are complete sets, not pages.
The SPA uses `GET /api/follows/tags/` (and pools) for two things: deciding a
follow toggle's state, and rendering every card on the Followed page. With the
standard 48-item pagination that silently broke past 48 follows — a 49th tag
could not be followed (the toggle never saw it) and the page hid it. These
tests pin the unpaginated behaviour.
"""
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from rest_framework.authtoken.models import Token
from apps.follows.models import FollowedPool, FollowedTag
User = get_user_model()
class FollowListTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="follow-user", password="follow-pass-123456"
)
self.other = User.objects.create_user(
username="follow-other", password="follow-pass-123456"
)
self.client = self.api_client(self.user)
self.other_client = self.api_client(self.other)
def api_client(self, user):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=user).key}"
)
return client
def test_tag_list_is_not_paginated_past_48(self):
FollowedTag.objects.bulk_create(
[
FollowedTag(user=self.user, tag=f"tag_{index:03d}")
for index in range(60)
]
)
response = self.client.get("/api/follows/tags/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, list, "the list must not be paginated")
self.assertEqual(len(data), 60)
self.assertEqual(data[0]["tag"], "tag_000")
self.assertEqual(data[-1]["tag"], "tag_059")
# The shape the SPA uses for its follow state.
self.assertIn("unseen_count", data[0])
self.assertIn("id", data[0])
def test_pool_list_is_not_paginated(self):
FollowedPool.objects.bulk_create(
[
FollowedPool(user=self.user, pool_id=59000 + index, name=f"pool {index}")
for index in range(55)
]
)
data = self.client.get("/api/follows/pools/").json()
self.assertIsInstance(data, list)
self.assertEqual(len(data), 55)
def test_lists_only_contain_the_callers_follows(self):
FollowedTag.objects.create(user=self.user, tag="mine")
FollowedTag.objects.create(user=self.other, tag="theirs")
mine = self.client.get("/api/follows/tags/").json()
theirs = self.other_client.get("/api/follows/tags/").json()
self.assertEqual([row["tag"] for row in mine], ["mine"])
self.assertEqual([row["tag"] for row in theirs], ["theirs"])
def test_anonymous_cannot_list(self):
self.assertEqual(Client().get("/api/follows/tags/").status_code, 401)
self.assertEqual(Client().get("/api/follows/pools/").status_code, 401)
+9 -2
View File
@@ -36,10 +36,16 @@ class FollowedTagViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Tags the current user follows."""
"""Tags the current user follows.
Deliberately unpaginated: the SPA treats this as the complete set (follow
toggles read their state from it and the Followed page lists every card),
so a 48-item page silently broke following past 48 entries.
"""
serializer_class = FollowedTagSerializer
permission_classes = [IsAuthenticated]
pagination_class = None
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
@@ -112,10 +118,11 @@ class FollowedPoolViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Pools the current user follows."""
"""Pools the current user follows (unpaginated, like the tag list)."""
serializer_class = FollowedPoolSerializer
permission_classes = [IsAuthenticated]
pagination_class = None
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
@@ -9,6 +9,8 @@ import type { UserPreferences } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
const LANDING_OPTIONS: { value: NonNullable<UserPreferences["landing_page"]>; label: string }[] = [
{ value: "library", label: "Library" },
{ value: "online", label: "Online" },
@@ -26,6 +28,7 @@ export function PreferencesCard() {
per_page: user?.preferences?.per_page ?? 48,
zoom: user?.preferences?.zoom ?? 190,
online_hot_default: user?.preferences?.online_hot_default ?? true,
e621_per_page: user?.preferences?.e621_per_page ?? 48,
});
const [saving, setSaving] = useState(false);
@@ -149,8 +152,32 @@ export function PreferencesCard() {
))}
</select>
</Field>
<Field label="e621 posts per page">
<select
className={inputClass}
value={values.e621_per_page ?? 48}
onChange={(event) =>
setValues((current) => ({
...current,
e621_per_page: Number(event.target.value),
}))
}
>
{E621_PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
</div>
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
e621 posts per page drives the Online browser and how many pool posts
load per request; e621 caps a request at 320.
</p>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Thumbnail size
@@ -13,7 +13,6 @@ import type {
FollowedPool,
FollowedTag,
FollowFeed,
Paginated,
} from "@/lib/types";
const ratingPill: Record<string, string> = {
@@ -207,11 +206,12 @@ export default function FollowedPage() {
const tagsQuery = useQuery({
queryKey: ["follows-tags"],
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
// Unpaginated: this is the complete follow list, shown as cards below.
queryFn: () => api<FollowedTag[]>("/api/follows/tags/"),
});
const poolsQuery = useQuery({
queryKey: ["follows-pools"],
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
});
const feedQuery = useQuery({
@@ -269,8 +269,8 @@ export default function FollowedPage() {
);
}
const tags = tagsQuery.data?.results ?? [];
const pools = poolsQuery.data?.results ?? [];
const tags = tagsQuery.data ?? [];
const pools = poolsQuery.data ?? [];
const feed = feedQuery.data;
return (
@@ -5,7 +5,7 @@ import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import type { FollowedPool, Paginated } from "@/lib/types";
import type { FollowedPool } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
@@ -23,12 +23,13 @@ export function PoolFollowButton({
const query = useQuery({
queryKey: ["follows-pools"],
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
// Unpaginated on purpose: the button must see every follow, not page one.
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
enabled: Boolean(user),
staleTime: 30_000,
});
const follow = query.data?.results.find((entry) => entry.pool_id === poolId);
const follow = query.data?.find((entry) => entry.pool_id === poolId);
const followed = Boolean(follow);
const mutation = useMutation({
@@ -4,7 +4,7 @@ import { useState, type MouseEvent } from "react";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { FollowedTag, Paginated } from "@/lib/types";
import type { FollowedTag } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
@@ -16,13 +16,14 @@ export function TagFollowToggle({ tag }: { tag: string }) {
const query = useQuery({
queryKey: ["follows-tags"],
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
// Unpaginated on purpose: the toggle must see every follow, not page one.
queryFn: () => api<FollowedTag[]>("/api/follows/tags/"),
enabled: Boolean(user),
staleTime: 30_000,
});
const normalized = tag.trim().toLowerCase();
const follow = query.data?.results.find((entry) => entry.tag === normalized);
const follow = query.data?.find((entry) => entry.tag === normalized);
const followed = Boolean(follow);
const mutation = useMutation({
+32 -4
View File
@@ -18,13 +18,14 @@ import {
type E621Post,
} from "@/lib/e621";
import { hotDefaultAction, HOT_ORDER } from "@/features/online/hotDefault";
import { schedulePreferenceSave } from "@/lib/preferences";
import { useE621 } from "@/store/e621";
import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui";
import { toast } from "@/store/toasts";
import { hasBackend } from "@/lib/backend";
const PAGE_SIZE = 48;
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
const EMPTY_POSTS: E621Post[] = [];
export default function OnlinePage() {
@@ -44,6 +45,8 @@ export default function OnlinePage() {
const [blacklistDraft, setBlacklistDraft] = useState("");
const tagInput = draft ?? tags;
const zoom = useUi((state) => state.zoom);
const perPage = useUi((state) => state.e621PerPage);
const setE621PerPage = useUi((state) => state.setE621PerPage);
const appliedHotDefault = useRef(false);
// Landing on Online follows the account's order:hot default: applied when
@@ -100,12 +103,12 @@ export default function OnlinePage() {
}
const postsQuery = useQuery({
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
queryKey: ["e621", "posts", tags, page, perPage, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPosts(effectiveCredentials(credentials), {
tags,
page,
limit: PAGE_SIZE,
limit: perPage,
signal,
}),
placeholderData: keepPreviousData,
@@ -166,7 +169,7 @@ export default function OnlinePage() {
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
}, [visiblePosts]);
const hasNext = posts.length >= PAGE_SIZE;
const hasNext = posts.length >= perPage;
function buildParams(tagValue: string, pageValue: number): URLSearchParams {
const params = new URLSearchParams();
@@ -223,6 +226,31 @@ export default function OnlinePage() {
</p>
</form>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Posts per page
</span>
<select
className={inputClass}
value={perPage}
onChange={(event) => {
const value = Number(event.target.value);
setE621PerPage(value);
schedulePreferenceSave({ e621_per_page: value });
setSearchParams(buildParams(tags, 1), { replace: true });
}}
>
{E621_PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<span className="text-[11px] leading-relaxed text-ctp-overlay0">
e621 returns at most 320 posts per request.
</span>
</label>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Tags on this page
+11 -6
View File
@@ -25,17 +25,18 @@ import { useIsMobile } from "@/lib/useMediaQuery";
import { useE621 } from "@/store/e621";
import { useUi } from "@/store/ui";
const CHUNK = 100;
const CHUNK_FALLBACK = 100;
async function fetchPostsChunked(
credentials: ReturnType<typeof effectiveCredentials>,
ids: number[],
chunk: number,
signal?: AbortSignal,
): Promise<E621Post[]> {
const posts: E621Post[] = [];
for (let index = 0; index < ids.length; index += CHUNK) {
const chunk = ids.slice(index, index + CHUNK);
posts.push(...(await fetchPostsByIds(credentials, chunk, signal)));
for (let index = 0; index < ids.length; index += chunk) {
const slice = ids.slice(index, index + chunk);
posts.push(...(await fetchPostsByIds(credentials, slice, signal)));
}
return posts;
}
@@ -54,8 +55,11 @@ export default function PoolDetailPage() {
const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
const isMobile = useIsMobile();
const zoom = useUi((state) => state.zoom);
// Pool posts load in chunks of the user's e621 page size (max 320).
const e621PerPage = useUi((state) => state.e621PerPage);
const chunk = Math.min(320, Math.max(24, e621PerPage || CHUNK_FALLBACK));
const [visibleCount, setVisibleCount] = useState(CHUNK);
const [visibleCount, setVisibleCount] = useState(chunk);
const [showBlacklisted, setShowBlacklisted] = useState(false);
const poolQuery = useQuery({
@@ -84,6 +88,7 @@ export default function PoolDetailPage() {
fetchPostsChunked(
effectiveCredentials(credentials),
postIds,
chunk,
signal,
),
enabled: postIds.length > 0,
@@ -319,7 +324,7 @@ export default function PoolDetailPage() {
<Button
variant="secondary"
disabled={postsQuery.isFetching}
onClick={() => setVisibleCount((count) => count + CHUNK)}
onClick={() => setVisibleCount((count) => count + chunk)}
>
{postsQuery.isFetching
? "Loading…"
+1 -1
View File
@@ -255,7 +255,7 @@ export async function fetchPostsByIds(
credentials,
"/posts.json",
{
params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 100) },
params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 320) },
signal,
},
);
+4
View File
@@ -12,6 +12,9 @@ export function applyPreferences(
if (typeof preferences.ordering === "string") ui.setOrdering(preferences.ordering);
if (typeof preferences.per_page === "number") ui.setPerPage(preferences.per_page);
if (typeof preferences.zoom === "number") ui.setZoom(preferences.zoom);
if (typeof preferences.e621_per_page === "number") {
ui.setE621PerPage(preferences.e621_per_page);
}
}
/** The browse settings currently in effect, as a preferences payload. */
@@ -22,6 +25,7 @@ export function preferencesPayload(extra?: UserPreferences): UserPreferences {
ordering: ui.ordering,
per_page: ui.perPage,
zoom: ui.zoom,
e621_per_page: ui.e621PerPage,
...extra,
};
}
+2
View File
@@ -33,6 +33,8 @@ export interface UserPreferences {
per_page?: number;
zoom?: number;
online_hot_default?: boolean;
/** Posts per e621 request for the Online browser and pool loading (max 320). */
e621_per_page?: number;
}
export interface User {
+6
View File
@@ -16,6 +16,9 @@ interface UiState {
/** Minimum thumbnail width in px; media grids derive their column count. */
zoom: number;
setZoom: (zoom: number) => void;
/** Posts per e621 request for the Online browser and pool loading (max 320). */
e621PerPage: number;
setE621PerPage: (value: number) => void;
}
export const useUi = create<UiState>()(
@@ -39,6 +42,9 @@ export const useUi = create<UiState>()(
zoom: 190,
setZoom: (zoom) => set({ zoom }),
e621PerPage: 48,
setE621PerPage: (e621PerPage) => set({ e621PerPage }),
}),
{ name: "j621.ui" },
),