Skip to content

Latest commit

 

History

History
1028 lines (909 loc) · 63.6 KB

File metadata and controls

1028 lines (909 loc) · 63.6 KB

Real-time (LiveView)

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 and live pages

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".

Installed on a phone (issue #1464)

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).

The service worker and Web Push (issue #1729)

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.

Read state: one marker plus two kinds of exception

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.

Quiet events: the like throttle and muted posts

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).

The bell's hover preview

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 screen mouseenter fires 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-unread off 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.

The notifications page (2026-09 timeline)

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/2 writes one to notification_visits when 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.exs checks Activity.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 network remote_reply_card/1, headed by a line naming what it answers and followed by the member's own answer (ReplyStatus, teased by PostTeaser, which skips a quoted opening). The card's Reply opens the composer under it: the InlineReply hook catches the link in the capture phase and pushes compose, and the Composer runs with host: :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 by Activity.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 since as well as its at (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.

CV updates (issue #980)

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? on work_experiences / educations / qualifications is 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 username welcome note

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 browser tab's teaser (issue #1681)

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 TabBadge hook reports document.hidden on 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 runs mount/3 again 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 no visibilitychange. 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_post is 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.

Browser notifications (issue #1249)

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.

The dead-render → socket-mount handoff (profile + feed)

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.

Live people counter

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/0 is called from Accounts.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/0 is called from Accounts.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 legacy nil-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.