Skip to content

Commit 026ab5e

Browse files
committed
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
1 parent 7c04983 commit 026ab5e

7 files changed

Lines changed: 302 additions & 80 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ A logged-in member without admin rights who opens `/admin` gets a 403 page that
7979
- **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.
8080
- **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).
8181
- **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.
8383
- **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.
8484
- **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.
8585
- **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

Comments
 (0)