You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 026ab5e
Browse filesBrowse the repository at this point in the historyBrowse files
Render feed/profile reply threads as one indented conversation
The feed nested only a reply's single direct parent, as a faint one-line
excerpt, and the dedup only dropped root posts. So a multi-post, multi-user
conversation (e.g. Stefan -> Ahmad -> Stefan) fragmented into overlapping
pairs with the middle post shown twice, and the nested posts carried no
like/repost/bookmark controls.
Now Posts.collapse_threads/1 folds each visible reply chain: it drops the
ancestors' standalone rows and hands each leaf its ordered :ancestors, so a
whole thread — however many posts or authors it spans — renders once.
post_thread_entry/1 renders that chain as a nested comment tree: every post
is a full card keeping its own action bar, indented one step further right
under the post it answers, so the reply depth reads at a glance.
The indentation is capped at 3 levels: a card's min-content is ~267px and a
360px phone leaves only ~60px of slack, so unbounded indentation scrolled a
deep thread sideways on a phone (measured); past the cap deeper replies stack
at the same indent. The feed batches engagement for the whole chain (no
per-card queries), and parent posts now preload :images/:tags so they render
as full cards.
Claude-Session: https://claude.ai/code/session_01PnDuuacaQkatn52c2NYZX8
Copy file name to clipboardExpand all lines: README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -79,7 +79,7 @@ A logged-in member without admin rights who opens `/admin` gets a 403 page that
79
79
-**Direct messages**: persisted 1:1 conversations (`Vutuv.Chat`) at `/messages`, with live delivery, typing indicators and online dots. Anyone validated can write to anyone, but the conversation lands directly only when the **recipient already follows the sender** — otherwise it is a **message request** the recipient accepts (explicitly or by replying) or declines; declining is silent (the sender cannot tell it from being ignored) and opening new requests is rate-limited. The shell badge counts conversations with unread messages, and a debounced email quotes the message and points the recipient back at the thread. Each member controls this on the notifications settings page: whether they are emailed about **every** unread message or only the **first** of a burst (the default), and how long a message may sit unread before the email goes out (0 to 120 minutes, default 15); every such email says which mode is active and deep-links to those settings.
80
80
- **Posts + newsfeed**: Markdown posts (up to 20k chars) with images and tags. An **`@handle` of an existing member is auto-linked** to their profile with the member's name as a hover tooltip, and a **`#hashtag` is auto-linked** to that tag's `/tags/:slug` page **only when the tag exists and has at least one visible member** (so a link never lands on an empty tag page) — everywhere the Markdown renderer runs (`VutuvWeb.Markdown`: posts, chat messages, ads, the RSS/JSON renderings), skipping entities typed inside code spans/blocks or existing links and resolving all of a body's mentions and hashtags in one batched query each. Everything post-related lives under `/:slug/posts`: the author archive (`/:slug/posts`, scopable to a year/month/day — `/:slug/posts/2026/06`), and permalinks keyed by the post's UUID v7: `/:slug/posts/:id` (non-canonical casing redirects to the lowercase URL). The feed at `/feed` is a member's **home once they follow at least one account** (`Vutuv.Social.follows_anyone?/1`): logging in then lands here, and the vutuv logo / visiting `/` redirect here (`VutuvWeb.Home`). A member who follows nobody yet (most visibly a brand-new sign-up, whose feed would be empty) lands on their own **profile** instead, where they can fill it in and find people to follow. It is a LiveView: a collapsed compose tile (the same dashed `<.empty_add>` tile as the profile's Beiträge section) expands the inline composer, a pull-model timeline (own + followed authors' posts **and reposts**, with a "Reposted by X" line) with cursor "Load more", a *"Show N new posts"* pill fed by `{:new_post, …}` / `{:new_repost, …}` broadcasts, and a desktop-only **"Who to follow"** rail (most-followed members you do not yet follow, `Vutuv.Social.most_followed_users/1`, live follow). The profile page and the archive show the author's timeline (posts + reposts). Audiences are **deny-based** (`Vutuv.Posts`): a post with no denials is public; denials exclude groups of the author's followees, single users, or wildcards (`non_connections`, `non_followers`, `non_followees`, `logged_out`, `everyone`) — the composer offers presets (public / followers / connections / only me) plus a custom "Hide from…" sheet with a person typeahead. The search page (`/search`) also finds words in **fully public** posts (Postgres FTS over a generated `search_tsv` column, `websearch_to_tsquery`, 'simple' config); any denial keeps a post out of search. Any denial also hides the post from logged-out visitors and noindexes it; a followers-only post shows a follow teaser and a connections-only (mutual-follow) post a follow-each-other teaser to denied readers, every other denial 404s. Deleting a group that posts deny is refused (it would silently widen audiences).
81
81
- **Likes, bookmarks, reposts**: every post card carries a live action bar (`VutuvWeb.PostLive.Actions`, one embedded LiveView per card via `live_render`), so the like/repost/bookmark counters tick in real time on the feed *and* on classic pages (permalink, profile, archive). Counters are counted live from the `post_likes` / `post_bookmarks` / `post_reposts` rows and broadcast as absolute values on the post topic (`"post:<id>"`). Likes and bookmarks work on any visible post **and on any member** — from a profile a logged-in visitor can like / bookmark another member (`Vutuv.Social`, tables `user_likes` / `user_bookmarks`), a private, silent save that needs no follow or connection and is refused only across a block. The private saved-items hub at `/likes` and `/bookmarks` lists both saved **posts and saved people**, each under a **Posts / People** sub-tab, with a search box (post body + author name; person name, `@handle`, headline) and a sort control (newest / oldest / name), offset "Load more", and entries that appear and disappear live across sessions. **Reposts** work on public posts only and distribute the post into the reposter's followers' feeds; while reposts exist the author cannot restrict the post's audience (the composer pins it to Public, `Vutuv.Posts.update_post/2` enforces it) but can always delete the post.
82
-
- **Replies (threads)**: a reply is a **normal post** (own permalink, audience, images, tags, likes/reposts/bookmarks, shows up in the replier's feed and profile) plus a `post_replies` row naming the parent (`Vutuv.Posts.create_reply/3`). Replying works on **public** parents only (the reply button on restricted posts is disabled, like repost) and pins the parent's audience open like reposts do. Replies to replies are allowed. **A post is rendered by one shared component everywhere** (`VutuvWeb.PostComponents`): `post_thread_entry/1` shows a reply with the post it answers nested **inline** above it — a compact linked `post_preview/1` joined by a thread rail — so the feed, the profile Posts section, the saved lists, the archive and the permalink thread all read the same (each a single card of flat `divide-y` rows), instead of the feed's old flat "Replying to @handle" text banner. The notification page reuses that same `post_preview/1` for the post a like/reply quotes. The permalink page lists the visible replies oldest-first (nesting off there — the parent is the page itself), the action bar carries a live reply counter, and the parent's author gets a derived "replied to your post" notification (self-replies excluded). A reply **outlives its parent**: where the parent is gone the card falls back to a banner (which names the account as `@handle`, never the clear name) that degrades from "Reply to a now-deleted post by @handle" (profile link) to a nameless "Reply to a deleted post" once the account is gone too — no name is retained past account deletion.
82
+
- **Replies (threads)**: a reply is a **normal post** (own permalink, audience, images, tags, likes/reposts/bookmarks, shows up in the replier's feed and profile) plus a `post_replies` row naming the parent (`Vutuv.Posts.create_reply/3`). Replying works on **public** parents only (the reply button on restricted posts is disabled, like repost) and pins the parent's audience open like reposts do. Replies to replies are allowed. **A post is rendered by one shared component everywhere** (`VutuvWeb.PostComponents`): `post_thread_entry/1` shows a reply as a **nested conversation** — the posts it answers are stacked **above** it as full cards (each keeping its own like/repost/bookmark bar), oldest-first, with every reply **indented one step further right** under the post it answers (a left rail per level), so the reply depth of a whole multi-post, multi-author thread reads at a glance the way a threaded comment tree does, instead of the feed's old flat "Replying to @handle" text banner. On the feed and the profile Posts section `Vutuv.Posts.collapse_threads/1` folds each visible chain: it drops the ancestors' own standalone rows (so a middle post is no longer shown twice) and hands each surviving leaf its ordered `:ancestors`, so however many posts or authors a thread spans it renders once; the archive, saved lists and permalink fall back to nesting the single direct parent. All read the same (each a single card of flat `divide-y` rows). The notification page reuses the compact `post_preview/1` for the post a like/reply quotes. The permalink page lists the visible replies oldest-first (nesting off there — the parent is the page itself), the action bar carries a live reply counter, and the parent's author gets a derived "replied to your post" notification (self-replies excluded). A reply **outlives its parent**: where the parent is gone the card falls back to a banner (which names the account as `@handle`, never the clear name) that degrades from "Reply to a now-deleted post by @handle" (profile link) to a nameless "Reply to a deleted post" once the account is gone too — no name is retained past account deletion.
83
83
-**Post images**: uploaded eagerly in the composer (so inline `` references work before submitting; abandoned uploads are swept after a day), up to 10 per post, 6 MB each (`jpg/png/webp`, plus `heic` when the libvips build can decode it — capability-detected via `priv/heic_probe.heic`). All served versions are AVIF (see **Images** below), EXIF-autorotated and **metadata-stripped** (no GPS leaks); the original keeps its metadata in the private `originals/` tree and is never served. Every image byte goes through the authorizing proxy `GET /post_images/:token/:version` (`VutuvWeb.PostImageController`), so a post's audience guards its images too — served with `send_file` everywhere (the X-Accel-Redirect handoff was disabled after it failed in production; see Deployment). Legacy `…/feed.webp` URLs in old post bodies keep resolving.
84
84
- **Moderation (family-friendly by design)**: any member can report a post, a private message or a whole profile (quiet "Report" affordances on every post card, message bubble and profile footer; categories: not family-friendly, bullying/harassment, spam, other). A report from a reporter in good standing **freezes the content instantly** (`frozen_at`) — it vanishes for everyone but the owner and admins, with no public tombstone — and opens a `Vutuv.Moderation.Case`. The owner is notified (in-app + email) and can settle it **without an admin** at `/moderation/cases/:id`: delete it, edit it (auto-unfreezes; a re-report then skips self-service) or dispute it ("my content is fine" — stays frozen, escalates). Silence for 72h escalates too (`Vutuv.Moderation.Sweeper`), so the admin queue at `/admin/moderation` (a LiveView; the case page rules reload-free and drops back to the queue) only carries disputes, ignored cases, re-reports and profile cases. Admin rulings are one click: **uphold** (owner gets a strike: warning → one-week suspension → permanent deactivation; strikes expire after 12 months) or **reject** (unfreezes; rejections lower the reporter's trust, and reports marked *abusive* strike the **reporter** on the same ladder — reporting-as-a-weapon is treated as bullying). Reporters with a bad track record lose the instant freeze (their reports only flag for review), whole profiles freeze only on a **second** independent trusted report, and `/admin/moderation/reporters` shows every reporter's track record. Suspended/deactivated accounts cannot log in and disappear from feeds, profiles and search. House rules live at `/community`. **Reporting someone also separates the two accounts on the spot** (before any second report or admin ruling): connection and follows are removed and the 1:1 conversation is frozen for both sides; the report form warns a tied reporter up front (including that the separation de-facto reveals who reported), and after sending the reporter is told again (flash + a notification) that the pause works in both directions and is undone if admins find the report unfounded - a rejected case restores exactly what was cut (recorded in `moderation_severances`), an upheld one leaves the separation in place. Profile and message reports also capture a **full-page evidence screenshot** at report time (`Vutuv.Moderation.EvidenceScreenshot`: headless Chromium with a very tall window, trimmed by libvips; message threads render through the token-guarded `/moderation/evidence/:token` page), stored under the private `moderation_evidence/` tree and shown to admins via the authorizing `/admin/moderation/:id/evidence` route. Every case carries an **audit log** (`moderation_events`: reports, freezes, severances, owner self-service, escalations, rulings, strikes) rendered as the History timeline on the admin case page, and the urgent admin email names the profile, category and reporter's note instead of just a link.
85
85
- **Agent formats (markdown for agents)**: every public page is also served as **Markdown**, **plain text** (80 columns), **JSON** and **XML** under the same URL plus an extension — `/stefan.wintermeyer.md` / `.txt` / `.json` / `.xml`, the profile additionally as `.vcf` (vCard 3.0) — or via `Accept: text/markdown` / `text/plain` / `application/json` / `application/xml` content negotiation (the Cloudflare "markdown for agents" convention). Covered pages: profile, post permalinks, the post archive, follower/following lists, tag pages and the most-followed listing; `/llms.txt` documents the scheme. An **"Other formats" card** surfaces these links on the profile aside, the post permalink and the feed rail. Labels default to English (the canonical, cache-safe rendering — the session locale is deliberately ignored); `?lang=de` opts into a translated rendering, and the card links it for visitors browsing in German. These variants render the **anonymous public view** from one doc map per page (`VutuvWeb.AgentDocs.*Doc` — the single source of truth; a drift test fails when a page's HTML and its docs diverge). The **newsfeed** is the one exception: `/feed.md/.txt/.json/.xml` (`VutuvWeb.AgentDocs.FeedDoc`, negotiated by `VutuvWeb.NewsfeedController` — the controller in front of the `/feed` LiveView) render the signed-in viewer's own timeline, so they are login-only and sent `private, no-store` + `noindex/noai` (an agent-format request without a session 404s, and a feed has no `.vcf`). Documents carry `schema_version` + `generated_at`; responses carry `Content-Signal`, `Vary: Accept` and `x-markdown-tokens`. The signals render **two independent member choices** (`VutuvWeb.ContentPolicy`), both asked at sign-up and editable on the profile form: `noindex?` (search engines → `search=`, robots `noindex`) and `noai?` (AI agents/LLMs → `ai-train=`/`ai-input=`, robots `noai, noimageai`) — any combination is valid; pages that are noindexed page-level (profile sections, people lists, restricted posts) send every signal as `no`. Existing members were migrated as AI-opted-out (they were never asked) and can opt in on the edit form. The extension parsing lives in `VutuvWeb.Plug.AgentFormat` (endpoint; only the five known extensions are stripped, so dotted slugs keep working, and a `.md` URL that no controller answers 404s instead of serving HTML). **Agent readiness** (per specification.website): `/sitemap.xml` (chunked index over members/posts/tags, `Vutuv.Sitemap`), RSS 2.0 feeds with full post content (`/:slug/posts/feed.xml` per member, `/posts/feed.xml` site-wide, `VutuvWeb.Feeds`), robots.txt names the AI crawlers and declares draft `Content-Signal` directives from the one policy source (`VutuvWeb.ContentPolicy`, config `:ai_crawler_policy` — flips robots.txt and the response headers together), `Link` headers advertise llms.txt/sitemap/per-page alternates (`VutuvWeb.Plug.AgentLinks`), schema.org JSON-LD (Person on profiles, BlogPosting on permalinks, WebSite+SearchAction on the homepage — `VutuvWeb.JsonLd`, drift-tested against the doc builders), and `/.well-known/` serves agent-skills discovery (Cloudflare draft, digest-verified `SKILL.md`) plus `security.txt`
0 commit comments