Skip to content

Commit 718f01d

Browse files
authored
Der Feed lädt zehn Beiträge, und wie viele es sind, entscheidet das Mitglied (#1998)
Eine Ankunft trug vierzig Karten, von denen zehn im Dokument standen und die restlichen dreißig der Socket unaufgefordert nachschob. Das automatische Nachladen ist weg: eine Seite ist jetzt genau das, was gefragt wurde, und alles Weitere holt der Druck auf "Mehr laden". Wie lang diese Seite ist, sagt eine neue Voreinstellung (`Vutuv.Prefs`, `feed_page_size`, voreingestellt 10, bis 250) — dieselbe Zahl für die Ankunft, jedes Nachladen, einen Quellenwechsel und einen geöffneten Kalendertag, statt der vier verschiedenen, die hier standen. Eine Karte kostet echte Serverzeit (am 05.09.2026 auf der Produktion gemessen, verschränktes A/B über je zehn Abrufe: 188 ms für ein Dokument mit zehn Karten gegen 101 ms für dieselbe Seite ohne), und abwägen kann das nur, wer beides erlebt: die Wartezeit und die Knopfdrücke. Zu ändern unter der Zeitleiste selbst und auf der neuen Seite /settings/feed. Der Kalendertag verliert dabei seine eigene Hundertergrenze. Sie hat nur für die Tage zwischen der Seitengröße und hundert etwas bewirkt — und das waren genau die, für die ein `?day=`-Link 300 bis 850 ms und 1,3 MB kostete; wer den ganzen Tag will, hat den Knopf dafür weiter darunter. Diesen Text hat ein KI-Agent in meinem Namen geschrieben. Ich weiß, dass das problematisch ist. Claude-Session: https://claude.ai/code/session_01NmkMzEZBiSXhjGiYMtVpfw
1 parent 18cdcf1 commit 718f01d

28 files changed

Lines changed: 2421 additions & 1781 deletions

.claude/rules/design.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/architecture/posts-and-feed.md

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -341,43 +341,45 @@ whom being seen decides whether they come back. Following somebody here
341341
the click read as if it had undone something — and only a fresh draw retires
342342
them.
343343

344-
**Three page sizes, and every difference is deliberate.** A mount loads **40**
345-
entries (`@first_page_size`), every "Load more" after it **20** (`@page_size`),
346-
and a source switch **10** (`@filter_page_size`). The arrival page is the one
347-
page nobody asked for, so it is the one that has to carry the reader past the
348-
first few scrolls without a round trip; an older page is fetched while they are
349-
still reading and can afford to be half of it; and a switch is a wait with
350-
nothing on screen at all, where twenty rendered cards are the bulk of the second
351-
it takes on a slow line, for a screen that holds three or four.
352-
353-
**The arrival is forty cards, but the HTML document carries ten**
354-
(`@first_render_size`), and the socket appends the other thirty as soon as it
355-
connects (`:fill_arrival`, which is `append_older_page/2` — the same act as
356-
pressing "Load more", so the dedup rule has one owner). Rendering a card costs
357-
about **10 ms** of server time on production, measured two ways on 2026-08-31 —
358-
a twelve-day regression across `?day=` volumes and a twenty-run paired A/B
359-
against an empty day, agreeing on 10.2 and 10.0 — so the forty were roughly
360-
400 ms of the ~660 ms the browser waited for its first byte, spent drawing
361-
thirty-seven cards below a fold that shows two or three. Ten is the same number
362-
`@filter_page_size` uses and for the reason written there: it is what a screen
363-
holding three or four cards needs in order not to run out. What changed is only
364-
that an *arrival* is now judged by the same standard as a switch.
365-
366-
This is **not** the lazy discovery rail of #1229, which was reverted the same
367-
day because content popping in where the reader was looking read as slowness:
368-
the fill lands below the tenth card, off-screen, and a reader who never scrolls
369-
never learns it happened. A fill is owed to the page that asked for it
370-
(`:owes_fill?`) and is dropped if the reader has meanwhile switched sources or
371-
opened a calendar day — appending an older page to a day would spill posts from
372-
before it into that day. `more?` comes
373-
from the same query in all three cases, so a short page still knows there is
374-
more and the button below picks the rest up. The `/feed.md|txt|json|xml`
375-
siblings take the arrival page as *their* page size, every page of them, and
376-
they read it off `Feed.first_page_size/0` rather than a mirrored constant. The
377-
**size** is all they share: the document is deliberately the whole feed, with no
378-
source filter applied, because it carries none of the switches the member has
379-
here and narrowing it by one they cannot see would hand an agent a document with
380-
no way to ask for the rest.
344+
**One page size, and the reader owns it.** The arrival, every "Load more", a
345+
source switch and an opened calendar day all ask for the same number:
346+
`Vutuv.Prefs.feed_page_size/1`, a member preference defaulting to **10** and
347+
bounded at 5 to 250, read once in `mount_feed/3` and parked on the socket
348+
(`page_size/1`), so nothing within one visit can disagree about it. It replaced
349+
four constants (40 for a mount, 20 for a "Load more", 10 for a switch, a flat
350+
100 for a calendar day), each with its own argument — and every one of those
351+
arguments was really an argument about *waiting*. A card costs real server time:
352+
**188 ms** for a ten-card `/feed` document against **101 ms** for the same page
353+
carrying none, measured on production on 2026-09-05 in a paired A/B of ten runs
354+
each (an earlier measurement on 2026-08-31, before the hashtag cache, put a card
355+
at ~10 ms). The only person who can weigh that wait against pressing a button
356+
again is the one doing both, so they set it — from the chip row under the
357+
timeline (`page_size_chips/1`, which stores the choice and reloads the timeline
358+
at the new length) or on `/settings/feed`.
359+
360+
**The document carries the whole arrival, and nothing arrives behind the
361+
reader's back.** There is no socket fill: what a page holds is what was asked
362+
for, and what comes after it is asked for by pressing "Load more". (An earlier
363+
release split the arrival — forty cards, of which the HTML held ten and the
364+
socket appended thirty on connect — which is what the one number replaced.)
365+
`more?` comes from the same query in every case, so a page always knows whether
366+
there is more and the button below picks the rest up.
367+
368+
**A calendar day is a page like any other.** It used to be fetched whole up to a
369+
hundred, on the argument that a day is a bounded thing the reader asked to see;
370+
since the limit is an upper bound, a day smaller than the reader's page still
371+
arrives whole, so that argument only ever reached the days between their number
372+
and a hundred — and those are exactly the ones that made a `/feed?day=` link
373+
cost 300 to 850 ms and 1.3 MB. "Load the whole day" (capped at `@day_all_limit`,
374+
1,000) is still under a busy one for the reader who wants all of it.
375+
376+
The `/feed.md|txt|json|xml` siblings take that preference as *their* default page
377+
size, capped at the 100 an explicit `?limit=` is clamped to, so a member's
378+
browser setting cannot build a bigger document than any caller may request. The
379+
**size** is all they share with the HTML page: the document is deliberately the
380+
whole feed, with no source filter applied, because it carries none of the
381+
switches the member has here and narrowing it by one they cannot see would hand
382+
an agent a document with no way to ask for the rest.
381383

382384
**A post that arrives while somebody is reading is a fourth page, of one.** It is
383385
drawn into the timeline at the moment it arrives (`queue/2` — the row on top, the

docs/architecture/settings-and-account.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,21 @@ they cannot drift. If an editable area is not on it, it does not exist.
1818
| Profile | Basics & photos, Username, Experience, Education, Certificates & licenses, Language skills, Tags, Organizations |
1919
| Contact details | Email addresses, Phone numbers, Addresses, Websites & links, Social media profiles, Messengers |
2020
| Notifications & feed | Notifications, Muted words & tags, Search & replace, Feed languages, Tags you follow, Find your contacts, Saved searches |
21-
| Appearance | Language & display, Data-saving mode |
21+
| Appearance | Language & display, Posts in your feed, Data-saving mode |
2222
| Privacy | Visibility, Blocked members, Automatic post deletion, Fediverse |
2323
| Account | Sign-in & security, Account activity, Import, Export, Organizations, Apps & API, Delete account (red) |
2424

2525
*Appearance* is the newest group and holds what vutuv looks and costs like
26-
to this member — the interface language, dates, maps and post display on one
27-
row, data-saving mode on the other — as opposed to what the site holds about
28-
them. *Language & display* used to sit under Account, which had reached its
29-
eight rows and left the data-saving switch nowhere to go but the feed group,
30-
where it was not about the feed.
26+
to this member — the interface language, dates, maps and post display on the
27+
first row, how long the feed is on the second, data-saving mode on the third —
28+
as opposed to what the site holds about them. *Language & display* used to sit
29+
under Account, which had reached its eight rows and left the data-saving switch
30+
nowhere to go but the feed group, where it was not about the feed. *Posts in
31+
your feed* (`Vutuv.Prefs`' `feed_page_size`, 10 by default and up to 250) landed
32+
here for the same arithmetic — "Notifications & feed" is at its eight rows — and
33+
it belongs beside data-saving mode anyway: both are the member deciding what a
34+
page costs them to load. The chip row under the timeline writes the same
35+
column, so most members never open this page.
3136

3237
Each row is a map with `:key` (the sidebar's active state and the hub's entry
3338
counts), `:label`, `:path`, a `:hint` line saying what is inside, `:terms`

lib/vutuv/accounts/user.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,10 @@ defmodule Vutuv.Accounts.User do
340340
# `Vutuv.Prefs.get/2`, never straight off the struct.
341341
field(:feed_tab_ticker?, :boolean)
342342
field(:feed_tab_ticker_seconds, :integer)
343+
# How many posts one feed page holds for this member — the arrival, every
344+
# "Load more", a source switch, an opened calendar day. nil = inherit the
345+
# installation default; read through `Vutuv.Prefs.feed_page_size/1`.
346+
field(:feed_page_size, :integer)
343347
# The same quote one surface further out (issue #1681): while this member's
344348
# vutuv tab sits in the background, a new post pages its first line through
345349
# the browser tab's title. nil = inherit the installation default; read
@@ -580,7 +584,7 @@ defmodule Vutuv.Accounts.User do
580584
# :email_confirmed? is NOT here either: it flips only via the login-PIN path
581585
# (Accounts.activate_user/1, its own narrow cast) — castable, it would let a
582586
# registration self-activate without ever proving control of an email.
583-
@optional_fields ~w(noindex? noai? notification_emails? dm_email_each_message? dm_email_delay_minutes email_on_endorsement? email_on_follower? email_on_reference_check? newsletter_emails? saved_search_emails? cv_update_notifications? thread_notifications? browser_notifications? show_online_status? show_mastodon_feed? mastodon_clients? show_code_stats? fediverse_followers? fediverse_reactions? fediverse_replies? also_known_as_input map_google? map_openstreetmap? map_apple? default_map_service post_lines_desktop post_lines_mobile post_hyphenate_desktop post_hyphenate_mobile notification_post_lines like_attribution? headline employment_status employment_status_visibility desired_salary_min desired_salary_currency desired_salary_period desired_salary_visibility desired_workplace_types first_name last_name middle_name nickname honorific_prefix honorific_suffix name_pronunciation gender birthdate birthdate_visibility locale date_region time_zone tag_list auto_post_deletion? auto_post_deletion_after_days auto_post_deletion_keep_photos? auto_post_deletion_keep_answered? auto_post_deletion_keep_bookmarked? auto_post_deletion_delete_replies? auto_post_deletion_min_likes auto_post_deletion_min_bookmarks auto_post_deletion_min_reposts feed_foreign_posts feed_languages feed_tab_ticker? feed_tab_ticker_seconds browser_tab_teaser? low_bandwidth?)a
587+
@optional_fields ~w(noindex? noai? notification_emails? dm_email_each_message? dm_email_delay_minutes email_on_endorsement? email_on_follower? email_on_reference_check? newsletter_emails? saved_search_emails? cv_update_notifications? thread_notifications? browser_notifications? show_online_status? show_mastodon_feed? mastodon_clients? show_code_stats? fediverse_followers? fediverse_reactions? fediverse_replies? also_known_as_input map_google? map_openstreetmap? map_apple? default_map_service post_lines_desktop post_lines_mobile post_hyphenate_desktop post_hyphenate_mobile notification_post_lines like_attribution? headline employment_status employment_status_visibility desired_salary_min desired_salary_currency desired_salary_period desired_salary_visibility desired_workplace_types first_name last_name middle_name nickname honorific_prefix honorific_suffix name_pronunciation gender birthdate birthdate_visibility locale date_region time_zone tag_list auto_post_deletion? auto_post_deletion_after_days auto_post_deletion_keep_photos? auto_post_deletion_keep_answered? auto_post_deletion_keep_bookmarked? auto_post_deletion_delete_replies? auto_post_deletion_min_likes auto_post_deletion_min_bookmarks auto_post_deletion_min_reposts feed_foreign_posts feed_languages feed_tab_ticker? feed_tab_ticker_seconds feed_page_size browser_tab_teaser? low_bandwidth?)a
584588

585589
# The ages the automatic post deletion offers (issue #1255), in days. A fixed
586590
# list rather than a free number field on purpose: this setting deletes
@@ -831,6 +835,13 @@ defmodule Vutuv.Accounts.User do
831835
greater_than_or_equal_to: notification_post_lines_min(),
832836
less_than_or_equal_to: notification_post_lines_max()
833837
)
838+
# The feed's page size, bounded by its registry entry so nobody stores a
839+
# feed of 4000 cards. A cleared field stays nil = inherit, like the counts
840+
# above.
841+
|> validate_number(:feed_page_size,
842+
greater_than_or_equal_to: Prefs.pref!(:feed_page_size).min,
843+
less_than_or_equal_to: Prefs.pref!(:feed_page_size).max
844+
)
834845
|> validate_inclusion(:dm_email_delay_minutes, @dm_email_delay_values)
835846
|> validate_inclusion(:employment_status, @employment_statuses)
836847
|> validate_inclusion(:employment_status_visibility, @visibilities)

lib/vutuv/prefs.ex

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ defmodule Vutuv.Prefs do
8989
values: ~w(original translate hide),
9090
group: :feed
9191
},
92+
# How many posts one feed page holds: the arrival, every "Load more", a
93+
# source switch and an opened calendar day all ask for this number.
94+
#
95+
# Ten because that is a screenful three or four times over, and because an
96+
# arrival is a wait with nothing on screen yet — a card costs real server
97+
# time (measured on production 2026-09-05: a ten-card /feed document ~188 ms
98+
# against ~101 ms for the same page with none), so the default is what a
99+
# reader needs rather than what they might eventually scroll to. The
100+
# ceiling is 250 for the reader who would rather wait once than press a
101+
# button twenty times; the floor is 5, below which the button IS the page.
102+
#
103+
# Its own group rather than `:feed`, because a group is what a reset link
104+
# clears: /settings/feed_languages resets `:feed` wholesale, and a member
105+
# putting their languages back to the site default must not silently lose
106+
# the page size they chose somewhere else.
107+
%Pref{
108+
key: :feed_page_size,
109+
type: :integer,
110+
default: 10,
111+
min: 5,
112+
max: 250,
113+
group: :feed_size
114+
},
92115
# The same idea one surface further out (issue #1681): while the vutuv tab
93116
# sits in the background, a new post pages its first line through the
94117
# browser tab's title instead of only putting a dot there. Off leaves the
@@ -188,6 +211,9 @@ defmodule Vutuv.Prefs do
188211
def label(:feed_foreign_posts),
189212
do: Gettext.gettext(VutuvWeb.Gettext, "Posts in other languages")
190213

214+
def label(:feed_page_size),
215+
do: Gettext.gettext(VutuvWeb.Gettext, "Posts loaded at once")
216+
191217
def label(:low_bandwidth?), do: Gettext.gettext(VutuvWeb.Gettext, "Low-bandwidth mode")
192218

193219
def label(:browser_tab_teaser?),
@@ -209,6 +235,13 @@ defmodule Vutuv.Prefs do
209235
"What your feed does with posts outside your chosen languages: show them as they are, translate them for you, or hide them. Posts that declare no language always show."
210236
)
211237

238+
def hint(:feed_page_size),
239+
do:
240+
Gettext.gettext(
241+
VutuvWeb.Gettext,
242+
"How many posts your feed shows before the “Load more” button, and how many that button adds. More posts mean a longer wait for the page."
243+
)
244+
212245
def hint(:low_bandwidth?),
213246
do:
214247
Gettext.gettext(
@@ -271,6 +304,7 @@ defmodule Vutuv.Prefs do
271304
@doc "The human label of a pref group."
272305
def group_label(:post_display), do: Gettext.gettext(VutuvWeb.Gettext, "Posts")
273306
def group_label(:feed), do: Gettext.gettext(VutuvWeb.Gettext, "Feed")
307+
def group_label(:feed_size), do: Gettext.gettext(VutuvWeb.Gettext, "Feed length")
274308
def group_label(:browser_tab), do: Gettext.gettext(VutuvWeb.Gettext, "Browser tab")
275309
def group_label(:bandwidth), do: Gettext.gettext(VutuvWeb.Gettext, "Bandwidth")
276310
def group_label(:privacy), do: Gettext.gettext(VutuvWeb.Gettext, "Privacy")
@@ -493,6 +527,32 @@ defmodule Vutuv.Prefs do
493527
"""
494528
def low_bandwidth?(user), do: get(user, :low_bandwidth?)
495529

530+
@doc """
531+
How many posts one page of this member's feed holds.
532+
533+
A named predicate for the same reason as `low_bandwidth?/1`: the feed, its
534+
size control and the agent-format siblings all ask, and a page size spelled
535+
out at four call sites is a page size that drifts.
536+
"""
537+
def feed_page_size(user), do: get(user, :feed_page_size)
538+
539+
# The sizes the two controls offer. Round numbers a reader picks from at a
540+
# glance rather than a slider over 5..250: the question is "a screenful or the
541+
# whole morning", not a precise count.
542+
@feed_page_size_steps [10, 25, 50, 100, 250]
543+
544+
@doc """
545+
The offered feed sizes, with `current` folded in when it is not one of them.
546+
547+
An admin may set any installation default inside the pref's bounds, so the
548+
five round steps are not the whole value space. Showing a member on 20 only
549+
the five would leave no chip marked and make the nearest press look like a
550+
no-op — so the value they actually hold is always one of the choices.
551+
"""
552+
def feed_page_size_steps(current) do
553+
[current | @feed_page_size_steps] |> Enum.uniq() |> Enum.sort()
554+
end
555+
496556
@doc """
497557
Drop every boolean-pref param that is not ticked, so the column stays NULL.
498558

0 commit comments

Comments
 (0)