vutuv adopts LiveView incrementally on top of classic controller + template pages. This document covers the always-live app shell, the live pages, and everything that updates without a reload.
The app shell VutuvWeb.ShellLive (sticky top bar + mobile bottom tab bar, with
live unread badges) is embedded in the shared app layout via live_render, so
the chrome and badges are live on every page.
Three badges, each a real count recomputed from its own source rather than a
tally the events nudge: messages (Chat.unread_conversations_count/1),
notifications (Activity.unread_notification_count/1, whose read state the next
section describes) and the Feed nav item
(Posts.unread_feed_count/1) — posts that reached the member's feed after the
marker the feed writes, and therefore the same figure on every page, /feed
included, where it stands beside the timeline's own "new posts" pill and empties
with it. The feed one lives with the feed:
posts-and-feed.md. The browser tab's own "(3)" and the
installed app's icon carry the first two only (push_badge/1); a busy feed would
drown a number that means "somebody wrote to you".
The site is installable: /site.webmanifest (VutuvWeb.PageController)
declares it a standalone app named after :node_name, the same string the
fediverse directories print, so an operator answers "what is this installation
called" once. Its load-bearing key is scope: "/". iOS decides from the
scope which links belong to the installed app and opens everything outside it
in a Safari overlay on top of the app — with no manifest at all that is what
a member got for ordinary navigation, which is what #1464 reported.
The document declares viewport-fit=cover, without which every
env(safe-area-inset-*) reads 0. That buys an app that paints edge to edge
and obliges the page to hand back the strips the device keeps: the tab bar
grows by the bottom inset and pads it away again (so its tabs keep their full
4rem), <main> and the footer reserve that grown height, the full-screen chat
subtracts it from its 100dvh, the lightbox controls sit inside their inset,
and the content columns take UI.gutter_class/0 — the page's 1rem gutter or
the sensor housing's inset, whichever is larger. Every one of those is exactly
the old value on a device that reports no inset, which is why the change is
invisible on a desktop. mobile_tab_bar_css_test.exs and
web_app_manifest_test.exs fail the build if a piece goes missing.
Three things then make the installed app read as an app rather than as a
bookmark (issue #1732). The icon badge: ShellLive.push_badge/1 already
sends the unread total (messages + notifications) to the TabBadge hook for the
browser tab, and the same number goes onto the Home Screen icon through
navigator.setAppBadge. One owner on each side, so the badge and the "(3) " in
front of the title cannot disagree. The call is guarded and never falls back:
most browsers do not carry the API, and where they do the platform ignores the
write outside an installed app.
An open page is not the only moment that number moves, though, and on a phone it
is the rarer one. The service worker writes the badge too, from the count
Vutuv.WebPush.Dispatcher.badge_count/1 puts in every push payload — otherwise a
message arriving overnight raises a lock-screen line and leaves the icon at last
night's number, since nothing is open to write it. It is read once per member per
push, and only where a device is actually subscribed. Two properties of that
count are load-bearing: it is the same two sources push_badge/1 adds up (the
feed's own badge is not among them), and it is never zero — notify/2 often
runs inside the transaction that produced the notification while the push task
reads from another process, so the row behind this very push may not be visible
yet, and a zero would take the badge off at the moment something arrived. Being
one short until the app is opened is the cheaper mistake.
On iOS the badge appears only once notifications are allowed (WebKit shows a
badge on a Home Screen web app from 16.4, gated on that permission), and only for
the copy on the Home Screen — the same site open in Safari has no icon to write
to. So a member who never turned on "Also notify me on this device when vutuv is
closed" under /settings/notifications, inside the installed app, sees no
number however correct the count is. That switch is also what a push needs to
exist at all, which is why the two questions have one answer.
#tab-badge is therefore the one element in the shell not gated on the
member: the logged-out shell renders it too and is pushed a zero, which is what
takes a signed-out member's count off the Home Screen icon. push_badge/1
records why the two cheaper spellings were rejected. What the gate becomes
instead is data-report-visibility, since only a member's tab has a visibility
answer worth a round trip.
The long-press shortcuts are four manifest entries: write a post, search,
messages, notifications. Not the feed, which is where start_url already lands a
signed-in member. Their labels are the one part of the manifest a person reads,
so VutuvWeb.Plug.Locale runs on that action alone — scoped to the action and
not to the :machine_docs pipeline, whose other documents are gettext-free and
public cached with no vary — and the answer carries
Vary: accept-language, which is why it is the one discovery document with a
cache line of its own. "Write a post" points at /feed#compose, the composer
deep link the "n" shortcut already uses from a page without one: the
manifest gets the caret that revealAndFocusComposer puts in the box, and this
app keeps one spelling of "open the feed ready to write".
The rest is install-dialog metadata: description
(VutuvWeb.OpenGraph.default_description/0, the same sentence the page carries
as its <meta name="description">), lang, dir, categories and
display_override. Still open on #1732: share_target (vutuv in Android's share
sheet) and screenshots (which earns a real install dialog instead of a thin
strip).
Everything above works only while a vutuv page is open: a Notification raised
by a tab dies with the tab, and the installed app could therefore never notify
anyone. assets/js/sw.js — hand-written, no Workbox — is served by
VutuvWeb.ServiceWorkerController at /sw.js, which is the root because a
worker controls only the directory it is served from. It is not an esbuild
entry point either: it is read at compile time (@external_resource), so it
ships inside the release with no asset step, and the controller prepends the
two things only the server knows — the digested paths of the tracked assets
(the cache key) and one generic notification line per kind per locale.
Its load-bearing rule is that HTML is never cached. A stored page carries a
stale CSRF token and whoever was signed in when it was stored, and the LiveView
socket would join against a document the server never sent. Only /assets/*
is cached (digested filenames, so immutable — and only where a digest manifest
exists, never in dev), plus /system/offline, the one document this app keeps.
That page borrows the error layout for exactly that reason: a whole
self-contained document with its styling inlined, the same problem a rescued
500 has. Full offline is not a goal.
What it may say is bounded by the same staleness. A failed navigation is
reported by the browser as one and the same nothing — no network, a captive
portal, DNS, or our own server being down — so the page says it cannot tell
which, asks the reader to come back in a few minutes, and ends on the operator
(Vutuv.Operator, shared with the 500 card as
VutuvWeb.ErrorHTML.operator_contact/1). It renders no timestamp, which would
be the minute the worker stored it, and no retry button, which would re-serve
this very page.
Vutuv.WebPush is the lifted crypto and delivery — VAPID (RFC 8292) and
aes128gcm (RFC 8291), no dependency, keys derived from secret_key_base.
It used to live in the Mastodon adapter, where a subscription hangs off an
access token an installed app does not have;
Vutuv.MastodonApi.WebPush is now a thin delegate that keeps the adapter's own
MASTODON_API_ENABLED gate, which deliberately does not reach a member's own
app. web_push_subscriptions is keyed on the endpoint — a browser's own
address at its push service — so the same phone signed in as somebody else
moves the row instead of leaving the previous member being woken by it.
Vutuv.WebPush.Dispatcher hangs off Activity.notify/2 beside the Mastodon
one, plus Vutuv.Chat's new-message broadcast, which never goes through
notify/2. Two switches must be on: the account's browser_notifications?
and the existence of a subscription row, which is the per-device answer,
because a subscription belongs to a browser and not to an account. The payload
carries no content — kind, id and destination — and the worker draws the
line ("New message on vutuv"), because what a push turns into is text on a lock
screen.
The worker does not decide when the "new version" bar appears, and that is
worth saying because it looks like the obvious signal. registration.waiting is
set from the moment a new worker installs until every vutuv tab is gone, so it
is still true on a page that has just arrived from the new release — the bar
came back on every page load until somebody pressed it, while the case it exists
for (a tab open for hours) barely fired at all, nothing calling
registration.update(). So the shell asks static_changed?/1 instead:
phx-track-static reports the bundle this browser is really running, and only a
document that predates the deploy is offered #sw-update ("A new version is
ready"). There is no way to put that bar away: this reader's markup, CSS and
JavaScript all come from a release that is gone, so postponing would leave them
on it for as long as the tab stays open. The worker's remaining job is carrying
the reload out — Reload posts skip-waiting to a waiting worker, or plainly
reloads when there is none.
The page also applies that bar by itself, while the tab is in the
background. The strip is deliberately quiet, because nothing is broken — a
dialog for news would spend on every asset deploy the attention the next real
one needs — and the one reader it is for is the tab nobody has clicked in for
hours, who is therefore the one least likely to notice it. So app.js reloads
at the moment that costs nothing: the tab is hidden, the reader sees no flash,
loses no scroll position and is asked nothing. ShellLive pushes
"version:ready" alongside the bar, because the browser cannot see it arrive —
a tab that was already in the background when the deploy landed hears its
socket reconnect and no visibilitychange ever fires. The push is what the
client listens for rather than a hook on the bar, for the reason everything
about this bar comes back to: the markup lands in a document running the
previous release's JavaScript, where an unknown hook name is an error and an
unknown window event is nothing at all. Which also means the release that
introduces this cannot use it — what auto-reloads a tab is always the code the
deploy before put there.
The reader coming back cancels the pending reload, and the wait before it is minutes rather than seconds — deliberately long for something nobody is watching, because what synchronises these tabs is the deploy itself: every socket reconnects within a few hundred milliseconds of the switch, so a narrow window would aim every hidden tab's full page render at a slot that came up seconds earlier. It reloads once per release per tab, keyed on the tracked bundle rather than on a clock, so a second deploy ten minutes or ten seconds later gets its turn while a document that kept answering "stale" is refused for good.
Anything the reader has written holds it, and that takes two questions because
each is blind where the other sees. On a classic page a field differing from
its own default is what unsaved work looks like — against the default and
not against emptiness, or a settings form full of stored values would read as
work in progress. But a LiveView form with phx-change has already sent every
keystroke and the server echoes it back into the value attribute, so the
default catches up and a half-written job posting reads as clean; a document
that has seen one trusted input or change covers that one. Trusted
matters: the app dispatches input at itself while a composer boots, and
counting that would strand /feed, the tab this is for.
And a document that must not be re-requested at all carries
data-no-auto-reload. It names the effect rather than a cause, because
there are two: root.html.heex stamps it from conn.method for a page
rendered in answer to a form submission (reloading raises the browser's
"resubmit?" dialog in a tab nobody is watching), and <.secret_once> stamps it
on itself, although its page is an ordinary GET after a redirect — the token it
shows exists nowhere else, only its hash is stored, and the flash carrying it
is spent by that render. A method check could never have seen the second one.
Which means a worker from the previous release keeps running, and nothing
bounds how long. This is the one asset that deliberately outlives a deploy,
so it is worth being exact about. A new worker appears only when /sw.js
changes byte-for-byte, and its body is the config prelude plus the file: the
version (the digested paths of the tracked assets, joined), the notification
lines, and sw.js itself. A deploy that touches none of those three ships an
identical worker, so the old one simply stays in charge. When it does differ, the new
worker installs and then waits: the old one stays in charge of every open
tab, serving /assets/* out of its own vutuv-<version> cache and handling
that tab's push and notificationclick with the strings table baked into it
at its release. It is replaced when the member taps
Reload (which posts skip-waiting, and only then does activate drop the
other caches and clients.claim()), or when every tab it controls is closed.
Note this is about the worker's lifetime, not the bar's: whether anybody is
offered a reload is the separate question answered above.
An installed app on a phone is closed rarely and reloaded more rarely still,
so "weeks" is the realistic upper bound, not "until the next deploy".
The consequence for whoever changes sw.js next: the push payload is a wire
contract with workers you already shipped, not an internal call. The server
sends kind, locale and url; a worker from an older release will receive
tomorrow's pushes and look them up in yesterday's table. Adding a kind is
therefore safe by construction — the handler falls back to activity for
anything its table has no line for, which is why that fallback is load-bearing
rather than defensive — while renaming or dropping a field is not, and fails
silently on exactly the devices nobody is testing on. The same holds for the
cache: activate deletes every key that is not its own, so two releases never
share one, but the old cache lives as long as the old worker does.
The Messages (/messages), Notifications (/notifications) and
Search (/search) pages are LiveViews under a live_session. The search
page itself is described in search.md.
The profile (/:slug, VutuvWeb.UserProfileLive) is a LiveView too —
embedded by its controller via live_render (so the
.md/.txt/.json/.xml/.vcf agent siblings keep flowing through the
controller).
The feed (/feed, VutuvWeb.PostLive.Feed) is fronted the same way by
VutuvWeb.NewsfeedController so its own agent siblings can be negotiated (see
agents-and-seo.md), so it is the one LiveView no longer in
the live_session.
The add-tag form (/settings/tags/new, VutuvWeb.TagNewLive) is the first
live /settings page: it previews the parsed tags while the member types and
saves over the socket (see
settings-and-account.md). The Fediverse follower
browser (/settings/fediverse/followers,
VutuvWeb.FediverseFollowersLive) is the other kind of live settings page: it
changes nothing, it finds something — search-as-you-type, a server filter,
sortable columns and paging over a follower list that can run to five figures,
with the whole view in the URL via push_patch (see
fediverse.md). It and its mirror image, the following
browser (/settings/fediverse/following,
VutuvWeb.FediverseFollowingLive), are the settings pages that have to keep
themselves current: what they show is decided on other servers and reaches us
through the inbox, so an Accept, a Reject, a Move, an inbound Follow or
Undo, a rename, a prune or an instance block reloads the open page
(:remote_follows_changed / :remote_followers_changed) instead of waiting for
somebody to hit reload. A row that moved sweeps once through a brand tint
(tr[data-row-changed]), because a table that silently rewrites itself under
the reader looks like a misread.
Every state-changing control fires a LiveView event, so the page never reloads: the follow pill, the header card's bookmark and like glyph toggles, the ⋯-menu mute/block (and unblock), the follower/following/who-to-follow follow buttons, and the tag-endorsement pills. The two save toggles raise no flash: the glyph fills, which says it where the member is already looking. The follower/following/connection counts and the tag-endorsement counts also update live over PubSub even when the change is made on another page or by another member (e.g. someone follows you from their feed); plain links (Message, Report, vCard, the agent-format links) stay navigation, and the post action bars are their own embedded live views.
In-app updates flow over Vutuv.Activity (Phoenix.PubSub on "user:<id>");
online status and typing use VutuvWeb.Presence.
A site-wide online dot (green badge on a member's avatar everywhere — lists,
profiles, post authors, the top bar) rides the same VutuvWeb.Presence: the
always-present shell tracks the current member online on one global topic and
pushes each viewer their own online-id set to a tiny JS hook that toggles the
dot on every <.avatar presence> in the page (classic controller pages
included). It is public except across a block (the shell filters each
viewer's set both ways) and each member can switch it off on the Privacy
settings page (show_online_status?), after which they are never tracked or
shown as online.
Post timestamps render server-side in the reader's own time zone and
date shape (VutuvWeb.UI.post_time/1 over Vutuv.ViewerClock, issue #1502; see
settings-and-account.md):
a post from today shows just the time ("09:50 Uhr"), yesterday's the
word plus the time ("Gestern, 09:50 Uhr"), older posts the full date. There is
deliberately no client-side rewrite, because the today/yesterday wording is
relative to a calendar day only the server knows the reader's version of —
instead Vutuv.DayClock broadcasts :day_changed on every whole UTC hour
(every IANA zone's local midnight falls on one), so every open feed / profile /
notifications / likes page rolls its stamps over at the reader's own midnight
with no reload. Berlin midnight is itself a whole UTC hour, so the one consumer
that really does count German days — the admin "new members today" pill — still
empties exactly then.
Anything that renders a calendar day has to hold it as state, not read the
clock while rendering. A clock read inside a component is only ever refreshed
by a render, and at midnight nothing asks for one: the tick has to change an
assign or the component is never re-invoked. The feed calendar
(VutuvWeb.PostLive.FeedCalendar) learnt this the hard way — it took its
today from Vutuv.ViewerClock inside the card, so a page left open past
midnight kept yesterday's date in the folded card, kept the today ring on
yesterday, and greyed the new day out as a future day, locking the reader out
of the day they were in. today is an assign the feed owns now
(:cal_today, moved by the same :day_changed handler that re-streams the post
stamps), and the notifications page has always done it this way (:today +
assign_sections/1). Rolling the clock over is testable rather than something
to find out about at midnight: Vutuv.ViewerClock.today/0 reads
:viewer_clock_now from application env when it is set, which
test/vutuv_web/live/feed_calendar_midnight_test.exs moves (async: false — it
is global, and every timestamp in the app reads it).
At the reader's midnight the tick re-inserts every card, so a card's reader
state has to survive a patch it did not ask for. VutuvWeb.Live.DayClockRestream
sends each shown entry again when the reader's day is no longer the one the
host last rendered for (the feed's :cal_today, the saved hub's :stamped_on);
on the other 23 ticks it sends nothing, since every stamp on a card is worded
by the day. morphdom drops any attribute the server did not render, so a
<details> the reader opened would fold shut. A disclosure whose state is the
reader's wears data-keep-open, which app.js's onBeforeElUpdated honours,
and only together with an id built from its own post: morphdom pairs an id-less
<details> by position, so without the id a page that swaps one post for
another in place would open the next post's panel unasked. The card fold
behind "Found through these servers" and "From other networks" (card_fold/1
in VutuvWeb.PostComponents) carries both for its two callers (issue #2200);
the content warning and the sensitive-picture cover carry them too. To see it
in a browser without waiting for midnight, open a panel, move the reader's
clock with Application.put_env(:vutuv, :viewer_clock_now, DateTime.add(DateTime.utc_now(), 1, :day)) and run
Phoenix.PubSub.broadcast(Vutuv.PubSub, "clock:day", :day_changed) inside the
running dev server (iex -S mix phx.server, or Tidewave's project_eval), then
Application.delete_env(:vutuv, :viewer_clock_now). Not
send(Vutuv.DayClock, :tick), which would arm a second hourly timer.
The layout is split into root.html.heex (document shell) and app.html.heex
(chrome), shared by classic controller pages and LiveViews.
Notifications are real data derived at read time from the existing event tables (followers, endorsements, connections — mutual follows —, replies, mentions, likes; retroactively); each entry links to what it reports (the post, the actor's profile), and a reply or like entry quotes the post it is about so the feed is scannable at a glance: a like quotes the liked post, a reply quotes both the member's own post and the reply itself (each truncated to its first lines and linked to its own permalink, the reply respecting post visibility so a restricted one never leaks).
A quote is formatted the way /feed formats a post, not shown as Markdown
source: it runs through VutuvWeb.Markdown.render_preview/3 into the
.markdown markdown--post body recipe, so bold, lists, links, @mentions and
#hashtags read as themselves and headings flatten to bold. Because the body
then carries links of its own, the quote is a block with the permalink as a
stretched link underneath it rather than one big <a> (an <a> inside an
<a> is invalid) — the arrangement the /notifications quotes use too.
Inline image references are dropped before the quote is cut: the quote is text,
so a picture must not eat a line of the budget.
How long a quote is follows the feed's own line counts: /notifications shows
the feed's cards since its 2026-09 rebuild, and the separate quote length it
once had (:notification_post_lines) is gone.
Thread participation is its own kind ("thread"): once a member writes in
a thread (they rooted it or replied in it), every later reply anywhere in
that thread notifies them too — not only direct answers to their own posts,
which stay the "reply" kind (an event is always exactly one of the two).
Answers from before the member joined the thread don't surface (they were on
screen when the member replied), own replies and blocked members never do.
The set "all replies of this thread" comes from post_replies.root_post_id,
the thread root denormalized onto every reply at creation (threading is
otherwise only a parent-pointer chain); a reply whose root was deleted carries
NULL there and stays out of thread events. Rows link to the new reply's
permalink and quote it; same-day events of one thread merge into one grouped
row. The write side (Vutuv.Posts.create_reply/3 via broadcast_reply/2)
pushes the same event live to every participant's badge.
Being named is its own kind ("mention"): a post whose body says
@handle notifies that member, wherever the post sits. Before it existed a
mention reached you only by accident — if the post happened to answer one of
yours ("reply") or to land in a thread you had written in ("thread"); a mention
in a standalone post, or in a thread you are not part of, notified nobody.
This is the one feed kind that cannot be derived from current state cheaply: a
mention is plain text in posts.body, so deriving it would mean an ILIKE over
every post on every unread count — and that count runs on every page render for
the shell badge. So Vutuv.Posts resolves the body once at save time (through
Vutuv.Mentions.mentioned_users/2, the same grammar the renderer links with)
and reconciles a post_mentions row per named member; the feed reads that
table like any other source. Create, reply and every edit re-derive the set, so
adding a name notifies, removing one takes the event away again, and the body
stays the source of truth — the table is only a resolved index.
Left out at write time, because they belong to the post and are re-derived on the edit that changes them: the author (naming yourself is not news) and anyone the post is not visible to. Left out at read time, because they change outside the post: a block either way (like thread events), and the precedence rule below. Mentions of an organization handle notify nobody — organizations share the handle namespace but have no feed.
One post, one row. The three post kinds are ordered reply > mention >
thread: an answer to your own post stays a "reply" even when it also names
you, and a mention supersedes the quieter "thread" event for the same reply. So
a single post never produces two notifications for the same reader.
post_mentions and handle_change_notifications are the two event tables
written for a feed kind rather than read from one that already existed.
Derived-feed-wise, read state is stored in exactly three places.
users.notifications_read_at is the marker: everything up to here has been
seen. Activity.mark_notifications_read/1 bumps it when the member opens
/notifications, and anchors it to the newest event rather than the wall clock,
so an event landing in the same second is not swallowed (the event tables keep
second precision and the unread filter is a strict >).
notification_post_reads holds the per-post exceptions, written by
Activity.mark_post_seen/2 when a member answers, likes, bookmarks or reposts a
post. Nobody does any of those four to a post they have not read, so whatever
the feed has to say about that post is news they already have — and the badge is
supposed to mean "things you have not looked at", not "things since your last
visit to /notifications". Before this, you could read an answer in the feed,
reply to it, and the badge would still insist on one unread notification until
you opened the page and dismissed it by hand.
The chokepoints are Vutuv.Posts's engage/4 (like / bookmark / repost, on the
idempotent repeat too), do_create_reply/4 (the parent), and the feed's "Show N
new posts" pill: clicking it is the member choosing to look at exactly those
posts, so PostLive.Feed's reveal marks the whole batch through the plural
Activity.mark_posts_seen/2 (one recount broadcast for the batch, not one per
post). Marking broadcasts :notifications_changed, the shell's
recount-from-source signal, rather than decrementing a tally, so the badge
cannot drift.
Which events a seen post clears is Activity.subject_post_id/1, and it is
deliberately narrow — only the three kinds whose subject is somebody else's
post: the answer to your post ("reply"), the answer elsewhere in your thread
("thread") and the post that named you ("mention"). A "like" names your own
post, and bookmarking or reposting your own post says nothing about having seen
who liked it, so those keep waiting for a real visit. Only the unread tally
consults the table (unread_notification_count/1); notifications_count/2 and
the feed itself do not, so the row stays listed and the pager's total is
unchanged — /notifications remains the log of what happened, it just stops
calling that row new. The page marks those rows with one extra query per page
(Activity.seen_post_ids/2), so the list and the badge tell one story.
notification_dismissals holds the per-event exceptions, written by
Activity.mark_notification_seen/3 when a member clicks the browser
notification that announced one event. A popup carries a single event and is
only ever raised while the member is somewhere else, so clicking it says "I
have seen this" about that event and about nothing else waiting on the bell —
which the marker cannot express, because moving it would swallow everything
older too. The badge therefore drops by one.
A row names its event by kind plus source_id, the id of the row the feed
derives that event from: the follow, the like, the endorsement, the image scan.
That pair is also the feed item's own id (Activity.event_id/2), which is what
lets the notifications page render the dismissed row as read from the same two
columns.
Which shape a kind's exclusion takes is a registry entry, beside its
read-marker arm, its feed source and its counts: dismiss: [{"like", :id}] and
unless_dismissed/4 is applied once, in total_count/4, rather than inside
seventeen count queries. :id covers all but one, because a count query counts
rows of the table it is named after and that table is its first binding — the
assumption since/2 already makes; the connection pair is two rows and names
itself by the later of them (:later_follow). cv_update declares nil: a
sitting is several CV rows grouped in Elixir under a synthesised id, so there is
no row for SQL to exclude. Activity.dismissable_kinds/0 reads straight off
those entries, so a kind cannot store dismissals the tally would then ignore.
The live push has to name the same row the tally counts, and the two are written
in different modules from different arguments, so Vutuv.Activity.notify_*
carries a :source_id, notify/2 stamps the derived event_id/2 on the push
(an event arriving twice then replaces its row instead of stacking another), and
notification_dismissal_test.exs asserts per kind that the push and the derived
item name the same row. Opening /notifications deletes the member's dismissals:
the marker now covers them, so the table only ever holds the exceptions that
still matter.
A post that takes off would otherwise ring its author's phone once per like.
Vutuv.Activity.LikeThrottle decides how the n-th like is announced: the
first 10 one by one, then only the jumps (25, 50, 100, 250, …) as "your post
now has N likes", and at the member's cap (:like_notification_cap, a
Vutuv.Prefs select, shipped default 50, "none" for no cap) a last notice
that further likes stay quiet. A favourite from another network counts
toward the same n; a re-share does not.
The author can also mute one post from its ⋯ menu
(Posts.mute_notifications/3, posts.notifications_muted_at): its likes,
replies, thread answers and reactions go quiet for the author alone, while
somebody else in the same thread still hears about it.
Quiet means no interruption, never hidden. notify/2 still broadcasts the
event with quiet: true (an open /notifications page lists it), but skips
both push fan-outs; the shell neither pops up nor recounts; the Mastodon
streaming socket drops it. The tally leaves it out through unless_muted/4:
a quiet like has quiet stamped on its post_likes / fediverse_reactions
row, and every event about a muted post is filtered by post id. The bell's
preview applies the same rule over items (silenced?/2).
Most of what the bell counts is worth knowing and not worth a trip: two people
liked a post, somebody followed you. Reading that on /notifications costs a page
load there and a page load back, so the number sat there being ignored. Resting
the pointer on the bell drops a small panel under it instead — one round kind
badge, who did what, one line of the post it is about, how long ago, six of
them at most and a "+N more" footer into the page — and looking away marks
them read. The line is NotificationLine.quote_line/3, the rule the
notifications page folds its reply lines with: a reaction quotes the post it
reacted to, a reply or mention the words that were written, all from one
visibility-scoped Posts.visible_posts_by_ids/2 for the panel. The gesture says
"I have seen this" as plainly as opening the page does, so it carries the same
weight.
Opening one of the rows says it about that one event; both gestures are below.
The panel is ShellLive's; only the gesture is the client's, because a hover is
not something a LiveView binding can see. The BellPreview hook pushes
bell:preview and bell:preview_close under three rules, each of which exists
because the close writes something:
- A hovering pointer or nothing (
matchMedia("(hover: hover)")). On a touch screenmouseenterfires on the tap that follows the link, so a member walking to /notifications would empty the badge on the way and arrive at a page with nothing marked new. The viewport width the bell is hidden below is a different question, and a touch tablet answers it wrong. - A dwell of 200 ms before opening. The bell sits between four other icons,
and a pointer travelling to the avatar crosses it; until the dwell passes
nothing is asked of the server. The hook also reads
data-unreadoff the wrapper, so a bell with nothing behind it costs no round trip and no query. - A grace period of 150 ms before closing, for a pointer landing on a row. The hook listens on the wrapper, not the link, so the panel hanging out of it is a descendant and travelling into the list never leaves the subtree.
What arrives while the panel is open must survive it, and that is the one
thing mark_notifications_read/1 cannot do: it sweeps the marker up to whatever
the newest event is at that moment, so a like landing during the read would be
marked read without ever having been shown. So Activity.unread_notifications/2
hands the open the instant it is showing (read_up_to, the newest event there
is — not the newest unread one, or a marker already past it would go back) and
the close hands that same instant to Activity.mark_notifications_read_up_to/2.
Everything after it is still new, and the badge says 1 the moment the pointer
leaves. That marker only ever moves forward, so a second tab that opened
/notifications in the meantime cannot be undone by a preview that started
before it, and a marker that does not move broadcasts nothing.
unread_notifications/2 reads the feed where the badge counts with SQL, so it
repeats the same three exclusions by hand — the read marker, seen_post_ids/2
and dismissed_event_ids/1 — or the panel and the number would disagree.
Nothing is marked read by the close alone: the marker's own
:notifications_changed broadcast is what recounts the badge, like every other
change.
Opening a row is the second gesture, and it reads exactly one event. The
click takes the member off this page, so the close never fires and the badge
would otherwise stand at what it said before — the number pointing at something
they were just shown and then followed. So each row carries the reference of the
event it stands for (Activity.dismiss_ref/1 again, this time off a derived feed
item, which knows only the id event_id/2 composed for it), and the hook hands
that back through the same notify:seen the browser popup uses before assigning
location.href: the badge drops by one, not to zero, since the rest of the
panel was shown and not opened. Navigation waits for the server's reply, with a
700 ms timer taking the member to the page anyway when the socket is slow or
already gone — a push in flight dies with the socket the navigation tears down.
A modified click (a second tab) leaves the panel standing under the pointer, so
that one is left to the close as before.
VutuvWeb.NotificationLive.Index renders the derived feed as one timeline
with the member's own looks drawn as lines. It replaced the filter chips,
the reply inbox and the cards grouped by post, because none of them answered
the question the page is for: a member who looked at 14:00, had no time to act
and came back at 18:00 found everything marked read. The read marker
(users.notifications_read_at) remembers only the last look, and every look
(the page, the bell's preview, an event arriving while the page is open) moves
it.
- Looks are rows.
Activity.record_notification_visit/2writes one tonotification_visitswhen the page's socket connects ("page") and when the bell's preview closes ("bell"). Looks within 15 minutes are one sitting: the row moves forward instead of adding another, so a reload or a second tab draws no second line. Rows older than 180 days are dropped as new ones are written. - "New" is measured from
Activity.previous_notification_visit/1, the look before the current sitting, so a reconnect inside the sitting does not swallow what the first look marked new (the read marker is the fallback for a member with no looks yet). A row the member already dealt with (:seen?, see the read-state section) is never new. VutuvWeb.NotificationLive.Timeline(pure) cuts the events into sections, one per reader's day and gap between two looks, and emits day headings, one "N new since your visit at 14:02" line, and one "You were here · 14:02" line per look (adjacent looks with nothing between them share one). Inside a section: every reply, thread answer, mention and reply from another network is a row of its own; likes and re-shares of one post are one row counted per person; followers and connections are one row, a follower and the connection that followed a moment later being one person; everything rarer is one row per event. Every kind in the registry lands in a row (notification_timeline_test.exschecksActivity.kinds/0).- Words are the feed's own cards. A reply is
post_card/1(flat, no reply banner, the preview clamp), a reply from another networkremote_reply_card/1, headed by a line naming what it answers and followed by the member's own answer (ReplyStatus, teased byPostTeaser, which skips a quoted opening). The card's Reply opens the composer under it: theInlineReplyhook catches the link in the capture phase and pushescompose, and theComposerruns withhost: :inline_reply, which stays on the page and reports{:composer_answered, id, post}. Without JavaScript the link still leads to the reply page. - Time travel.
?at=<look>shows the list as it stood at that look: nothing newer, "new" measured from the look before it, an amber banner with the way back.?day=<date>opens a day plus the one before it (the present shows today and yesterday). The feed's month calendar (VutuvWeb.PostLive.FeedCalendar,metric="notifications", no switch) is shaded byActivity.notification_counts_by_day/2; beside it the looks of the shown day are links. Both render twice, above the list on a phone and in the rail on md+. - Windows, not pages. Every notification source honours the cursor's
sinceas well as itsat(Vutuv.FeedPage's contract), so a two-day window or a month's counts read only that span. A window past 300 events offers the shared "Load more" inside it; "Earlier days" steps back two days. - Only words to me (
?only=words) keeps the cards alone. - Live: the present view rebuilds (debounced a second) when an event arrives and keeps the badge at zero; a day or a look in the past is a fixed window. The DayClock tick turns "Today" into "Yesterday".
Row times are the reader's own wall clock in their own date region (like post
stamps), server-rendered final with an ISO-8601 UTC datetime for machines.
One notification kind is not about something that happened to the reader: "@greta added a new position to their CV". A member who adds a new CV entry — a work experience, an education entry or a certificate / license — can tell the people who follow them, with one checkbox on the new-entry form (ticked by default, hidden while they have no followers). Only those three sections announce; the rest of the profile stays quiet.
One notification per sitting, not one per entry. Somebody filling in five
roles in one go is one piece of news, so the feed folds an author's announced
entries into sittings and renders one row that names them ("added 5 new
entries to their CV", each entry listed and linked, capped at five plus "and N
more"). A sitting is a gap-and-islands group: entries less than
CvUpdates.gap_seconds/0 (three hours) apart belong together, and a longer
quiet stretch starts a new one. Deliberately not a fixed three-hour raster —
that would split 08:59 and 09:01 into two notifications while merging 09:01 and
11:59 into one. In SQL it is lag() over the author's entries → a
"starts a new sitting" flag → a running sum() → GROUP BY (author, sitting),
all over the derived rows, so the unread badge counts sittings too and a burst
can never inflate it. The gap is baked into the SQL as a literal, not a query
parameter: a window expression repeated in an outer GROUP BY is matched
syntactically by Postgres, and two placeholders are not the same expression.
It is derived like every other kind, from the CV rows themselves
(Vutuv.Profiles.CvUpdates.feed_query/1 is the single rule behind the items,
the count and the read marker): so deleting the entry removes it from its group,
renaming the job renames it, and nothing is duplicated into a notifications
table. Who is told: everyone who followed the author before the entry
appeared (no backfill for a new follower), minus muted follows, minus readers
who switched the kind off.
Two flags carry it, one per side:
announce_to_followers?onwork_experiences/educations/qualificationsis the author's choice, cast only on insert (Vutuv.Profiles.CvSection.cast_announcement/2), so editing an old entry can never fire a second round and the LinkedIn import — which never sets it — stays silent.users.cv_update_notifications?is the reader's opt-out (default on), the one in-app kind that is switchable, on the notification settings page.
It never sends email. CvUpdates.announce/2 (called from the three create
actions and the API create) only adds the live push to the same set of
followers, so an open session's bell lights up at save time. The push carries
the whole sitting under its derived id — author plus the sitting's start,
the part that does not move as it grows (the one exception to the "live-" id
namespace in NotificationLive) — so a second entry updates that row in place
instead of stacking another one.
The very first thing a confirmed account finds in its feed is not about someone
else: "Ihr vutuv-Username ist @egon_mueller." vutuv generates the handle
from the member's name (Vutuv.Handles), so nothing in sign-up ever told them
what it is — this row does. Its links sit inside the sentence rather than
wrapping the whole row: the handle goes to the member's own profile,
/settings/username changes it, and /settings/import/linkedin rides along
because this is the one moment somebody arriving from LinkedIn still has that
profile in mind.
It is derived like every other kind, straight from the member's own users
row: no notification table, no live push and, deliberately, no email — the
PIN mail just landed in their inbox, and this is an in-app note, not a second
message. users.welcome_notified_at is both the gate and the timestamp: it is
stamped once, by the same Accounts.activate_user/1 branch that flips
email_confirmed? when the first login PIN is accepted, so the note appears
exactly at that moment. A NULL means no note, which is what every account
predating the feature keeps — the derived feed is otherwise retroactive, and a
welcome years after the fact would be nonsense.
The tab title has carried two marks for a while: (3) for unread messages and
notifications, and a • once a post arrived while the tab was in the
background. The dot says that something landed. For a few seconds the title
now says what — the author and the first words, paged through the tab a
line at a time, then handed back to the page's own title. It quotes the same
arrival the feed's "not read yet" card and its pill do — the source-tab ticker
this shipped beside is gone with the tabs — and all of them share the quote
(VutuvWeb.PostTeaser: who wrote it, how it opens, and the one refusal).
Measured in headless Chrome 151 against a real socket, a backgrounded tab reads:
0 ms "Feed - vutuv"
200 ms "• @wintermeyer: Frisch"
2000 ms "• geflasht, unter zwei"
4000 ms "• Sekunden bis der Kessel"
6000 ms "• +1 more post" (a second post landed at 2.5 s)
8000 ms "• Feed - vutuv" (the dot stays until they come back)
Both sources. ShellLive already receives {:new_post, …} on every page,
and now also takes the {:remote_feed_arrival, …} nudge it used to drop
through its catch-all. That nudge carries no entry, because whether the write
reaches this reader depends on their mutes, follow states, the audience and
their language filter — so the teaser asks their own sources
(Posts.newest_source_entry/3, the call the feed's ticker makes) and the
browser-tab dot for a fediverse arrival rides on that answer rather than being
pushed blind. A vutuv post's dot is unchanged: its fan-out is already scoped to
the author's followers. {:new_post, …} gained an at stamp for the lookup,
the way the fediverse nudge always carried one; a payload from the release
before this one has none and simply skips the teaser for that deploy window.
The lookup is the cost, so the window is the budget. This shell is mounted on every page of every logged-in member, so a quote built per arrival would turn one post by a well-followed member into thousands of feed queries in the same instant. Four rules bound it, and only the first is about taste:
-
Nothing is spent on a tab the member is looking at. The
TabBadgehook reportsdocument.hiddenon connect and on every change (tab:visibility), and the server refuses until it hears the tab is in the background. That also keeps the shell off the one page where the feed's own ticker is already saying this — and it is the capability handshake the feed's ticker needed a connect param for (issue #1679): only a bundle carrying this hook can send the event, so a document loaded before this release simply never teases, however many deploys behind it is.It reports on
reconnected()too, and that is not belt and braces:tab_hidden?lives in the socket, so a rejoin runsmount/3again and starts it at false. Nothing on the client volunteers the answer twice by itself —mounted()runs once (the element survives the patch) and a tab hidden throughout fires novisibilitychange. Without that callback every long-lived background tab fell silent after its first deploy, sleep or network blip, and stayed silent. The dot kept working (tab:new_postis pushed unconditionally and gated in the browser), which is why the feature read as simply broken while every test stayed green — found 2026-08-25 by driving a real headless Chrome across a forced reconnect. -
One quote per window. From the second arrival the quote gives up and becomes a count (
tab:teaser_more, "+2 more posts") — no query, and the window is not extended, or a busy source would own the tab. -
A silence after each window (
:tab_teaser_cooldown_ms, 30 s — much longer than the ticker's 2 s, because this one is a rate limit and not an animation). One socket therefore spends at most one lookup per open tab per half minute, however busy the network is. -
The silence is armed on every outcome, refusals included. A quote the reader may not be shown — a muted word, or sources that return nothing — has still spent its query, and a lookup retried on the very next arrival is the one branch with no rate limit at all. It is also worst for exactly the member who muted the word a busy account keeps writing.
Why frames and not a scrolling marquee. A hidden tab is where the browser owns the clock in the strongest sense: timers are clamped to roughly one per second, and Chrome drops a chained timer to one per minute once a page has been hidden for five minutes ("intensive throttling"), which is precisely the tab this is for. So there are about four usable frames, a character scroll would spend most of them re-showing words it already showed, and whatever stands last has to be a line that is still true a minute later — hence a count or the page's own title at the end, never half a sentence.
The hook asks for one second and the trace above shows two: wake-ups in a
hidden tab are aligned to whole seconds, so a timeout re-armed just after one
waits for the boundary after next. ShellLive's @frame_ms is therefore the
measured figure, not the requested one — it is what decides whether a
second arrival still reaches a running animation as a count, and sized at the
requested second the window closed at 3 s while the browser was on frame two.
The cut itself
(PostTeaser.title_frames/1, ~24 characters at word boundaries, a long URL
cut hard so it cannot swallow the frames behind it) is done on the server, so
it is testable without a browser and the wording stays in the reader's locale.
One member preference (Vutuv.Prefs, group :browser_tab):
browser_tab_teaser?, on, on /settings/preferences under the feed-tabs card.
The example there plays in the settings page's own tab through the same hook,
so it is the effect rather than a picture of it. The hint names the cost the
switch exists for: a tab title also shows up in screenshots, in a window
switcher and in a screen share.
Both examples on that page read their switch from the document, not from an
enclosing card. The kit's <.card> is a pile of utility classes and emits no
card class, so the .card the feed-ticker example climbed to was always null
and its play button had silently done nothing since v7.347.0 — found by driving
the page in a real browser, which is the only thing that could have found it.
A member can have vutuv open in a tab they are not looking at. The tab title
already carries the unread count; with Browser notifications switched on in
/settings/notifications, the same events also raise a real notification
through the browser's Notifications API.
The switch (users.browser_notifications?) is the one in-app notification
setting that defaults to off. Every other one is an opt-out, because it
only decides what a member finds when they come back; this one puts something
over whatever they are doing in another window, and switching it on is also
what makes a browser ask for permission. Off by default therefore means nobody
who did not ask is ever prompted.
ShellLive is what pushes them. It is on every page and already holds the
member's "user:<id>" subscription, so it sees the same {:new_notification, …} and {:new_message, …} that move the badges, and it sends the browser a
finished, translated line (notify:show) rather than a payload for the
client to word: only the server knows the reader's locale, and the page under
the bell must not say one thing while the popup says another. Both surfaces
therefore share VutuvWeb.NotificationLine — extracting it is what surfaced
that four everyday kinds (like, follower, connection, endorsement) were spelled
only in the notifications page's grouping code, so a single one of them fell
back to the untranslated English the event was stored with. Invisible there
(its rows always take the grouped branch), very visible in a popup, which is
one event by definition.
That module owns the destination too (notification_target/2), which is
the half easiest to leave behind: clicking the popup opens the post, the case
or the profile it named, and only a kind with no page of its own falls back to
/notifications. The click also puts that one event to rest: the push
carries an ack (Activity.dismiss_ref/1), the hook sends it back as
notify:seen, and the shell records a per-event dismissal — see "Read state"
above. Navigation waits for the server's reply, because assigning
location.href tears the socket down and a push in flight would go with it; a
700 ms timer takes the member to the page anyway when the socket is slow or
already gone. A popup is raised precisely when the member is not looking at
vutuv, so a list to hunt through is the one place that costs most. The third
per-kind wording, VutuvWeb.NotificationDigestText, stays separate on purpose
(a digest mail names the actor inline and by @handle) — a new kind is spelled
in both, and each moduledoc says so.
The WebNotify hook in app.js owns the two questions the server cannot
answer.
Is the member looking at vutuv? document.hidden alone is too narrow: it
is false for a vutuv tab that is frontmost in a window sitting behind the
editor somebody is actually working in, which is the case the issue is about.
So the test is document.hidden || !document.hasFocus().
Did this browser grant permission? The switch lives on the account and
follows the member to every machine; the permission belongs to one browser
profile and does not travel at all. So a member who switches the feature on at
their desk and later opens vutuv on a laptop gets a one-line prompt in the
shell offering to ask that browser, dismissible per browser (localStorage).
It cannot be a prompt on page load: Firefox and Safari refuse
requestPermission() without a user gesture, and Chrome downgrades one. The
settings card asks the moment the box is ticked (that click is the gesture)
and reports what this browser answered — never asked / will show them /
blocking them / cannot show them — because the stored setting alone cannot say
whether this machine will show anything.
The card also offers Send a test notification, and it deliberately makes
the round trip — shell socket, server, hook — rather than raising one locally in
JS. Permission granted is only the last link in the chain, so a local popup
would answer a question nobody asked. Its payload carries test: true, which is
what lets the hook show it although the member is plainly looking at the page;
without that the away-gate would swallow every press and the button would do
nothing. It also skips the standing-preference gate, because the useful moment
to press it is right after ticking the box and before saving. The button waits
for the hook's vutuv:notify-shown and says so if nothing comes back, so a
silent system is told apart from a silent socket.
Each stream carries one tag (vutuv-activity, vutuv-messages), so a burst
of ten likes replaces itself into a single popup and four open vutuv tabs raise
one between them; a replacement is silent, so only the first of a burst makes a
sound. A message notification names neither sender nor text — the broadcast
carries neither, and a direct message is the last thing that should be legible
over somebody's shoulder.
Every LiveView visit computes its data twice: once for the HTML the visitor
sees immediately (the dead render) and once when the websocket connects and
mount/3 runs again in a fresh process — identical data, seconds apart,
~50 queries each on the profile. VutuvWeb.Live.MountHandoff (an ETS
table + sweeper in the supervision tree) lets the dead render pass its
finished work to the connected mount: the dead mount stashes the assigns it
computed under {authenticated viewer id, subject}, the connected mount
takes (consumes) them and skips the reload. UserProfileLive stashes the
assigns load_profile/1 added (diffed, not listed, so new assigns ride
automatically) and recomputes only the two connected-only slices (social-feed
ETS reads, code-stats refresh request); PostLive.Feed stashes its
feed_payload/1 map and rebuilds the stream from it (a consumed
LiveStream struct must never ride a handoff — it would replay empty).
It is deliberately not a cache: single-use (:ets.take/2, so a
reconnect after a blip or deploy full-loads), keyed by the server-side
authenticated viewer on both ends (never by anything the client sent;
anonymous visitors — mostly crawlers whose socket never connects — are never
stashed for), expired after ~15s, and fail-closed (any miss runs the normal
full load). The accepted trade: changes landing in the sub-second gap
between the two renders are not re-read at connect; both pages subscribe to
their PubSub topics at connect, so the next event heals the snapshot.
Regression tests in user_profile_perf_test.exs and
post_feed_live_test.exs pin both sides: a hit connects on a handful of
queries, a consumed stash still full-loads.
The middle of the top bar shows, on every page, the exact number of people around this installation and ticks it up in real time. That figure is two populations added up, because a reader asking how big this place is does not care which side of the fence somebody stands on:
- the confirmed members here (
Vutuv.Accounts.count_users/0), and - the distinct remote accounts that follow a member, a page or a topic of
this installation from the Fediverse
(
Vutuv.Fediverse.distinct_follower_count/0).
Nobody is counted twice. fediverse_followers holds one row per (actor,
followed thing), so one Mastodon account subscribed to two members and three
tags owns five rows and is one person — hence a count(distinct actor_uri)
rather than a row count (the row figure is what Fediverse.stats/0 reports to
the admin dashboard as remote_followers, and it stays that). Actors on our
own hosts (the site, its www. alias, the tag host) are left out: such a
person is already in the member half. What the count cannot see is one human
running two Mastodon accounts, or a member who also follows us from elsewhere —
those are two accounts, and two is what it says.
Vutuv.PeopleCounter keeps both halves in a lock-free :atomics cell (slot 1
members, slot 2 Fediverse accounts, ref in :persistent_term), so the
per-render read (counts/0) and the two member writes are O(1) and never hit
the database — a signup spike just races on one atomic add.
Both member writes are conditional on the account being one the advertised
total counts, i.e. a confirmed one (Accounts.count_users/0 counts by
account_confirmed_row/1):
increment/0is called fromAccounts.activate_user/1, on a genuine first confirmation (email_confirmed?false → true), not at registration — an unconfirmed sign-up is not a member yet (issue #781).decrement/0is called fromAccounts.delete_user/1, the deletion chokepoint, so a departure shows up at once instead of waiting for the next reconcile. The same function deletes abandoned sign-ups, which were never counted, so it only ticks down for an account that was confirmed (a legacynil-activated one counts as confirmed). The cell is unsigned, so a subtraction that would cross zero — only reachable in the sub-second before the first reconcile seeds it — is clamped instead of wrapping to 2^64-1.
The two halves move in deliberately different ways. A sign-up or a deletion
happens in this application, so it ticks the member slot at once. A remote
Follow arrives in the inbox, where the interesting question is not "one more"
but "is this account already counted somewhere else" — a question only the
database can answer. Rather than teach eight write paths (three add, three
remove, the pruner, an instance block) to ask it and risk one of them
forgetting, the owner process re-reads the whole head count once a minute.
So a new Fediverse follower shows up within a minute rather than instantly, and
there is exactly one place that decides what the figure means. The read is a
single aggregate over fediverse_followers, served by that table's
actor_uri index.
A single owner GenServer seeds both slots from the DB at boot, re-reads the
authoritative member count on a slow timer (self-healing against any
out-of-band change) and the Fediverse head count on the one-minute timer, and
broadcasts {:people_count, %{members:, fediverse:, total:}} only when the
figures changed, so a burst coalesces into at most one PubSub message per tick
instead of a fan-out storm.
Two readers consume that broadcast:
The top bar's people total (#people-total in ShellLive) is on every page.
Every socket subscribes — logged in or not, since the total is public — and
takes the new figures straight from the message. It is delimited_count/1, the
exact grouped figure, never a compacted "60K": a rounded total would never
visibly move, and moving is the point. The visible word beside it is
"people"/"Personen".
The breakdown rides the title, the plain total rides the aria-label,
and that split is deliberate. Hovering asks "what is this number made of", and
the answer does not need to repeat the figure the cursor is on, so the title is
"5,508 vutuv members plus 412 Fediverse accounts that follow them" (the plural
follows the Fediverse half — that is the number that is genuinely 1 on a
young installation). An aria-label, though, replaces the element's own
text for a screen reader, so it stays the plain "5,950 people": the visible
label has to be inside the accessible name (WCAG 2.5.3), and the breakdown
would push the figure out of it. An installation nobody follows from out there
gets the plain total in both. Zero renders nothing (the window before the first
reconcile), and the slot around it is always rendered so the bar keeps its
shape either way. The pill links to the public member directory at
/system/members, which deliberately does not repeat the breakdown — that
page prints the count it lists and nothing else (see
agents-and-seo.md).
The second reader is admin-only: the top bar's "new members today" pill
(#new-members-today in ShellLive), which shows how many sign-ups confirmed
since Berlin midnight and links into /admin. Only an admin socket runs its
query (every socket receives the messages, but the recount is gated on
user_admin?), and only when the member half is what moved — a Fediverse
follower arriving says nothing about today's registrations. The pill is
rendered only above zero, so a quiet day adds no chrome. Each such message
makes an admin socket re-read Vutuv.Dashboard.registrations_today/0 (the
figure the admin dashboard's "New today" tile shows) rather than adjusting a
running tally, so it cannot drift; Vutuv.DayClock's midnight tick empties it
out for the new day.