Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/architecture/agents-and-seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ member's name (and name + role queries):
`:header_job` the profile header shows; `og:title` shares it. A member with
neither keeps the bare name.
- **ProfilePage JSON-LD** (`VutuvWeb.JsonLd.person/5`) carries the fields
search engines document for profile pages: `dateCreated`/`dateModified`
(account timestamps), `alternateName`/`identifier` (the handle),
search engines document for profile pages: `dateModified` (never
`dateCreated`, which would say how long the member has been here, and vutuv
shows that nowhere), `alternateName`/`identifier` (the handle),
`interactionStatistic` (followers as a FollowAction counter),
`agentInteractionStatistic` (posts as a WriteAction counter) — plus the
Person entity enriched from what the page already loaded (accuracy rule,
Expand Down Expand Up @@ -384,7 +385,7 @@ are masks computed from pixel coordinates. It replaced the square avatar as
thumbnail on Slack and Signal), and the wide 1.91:1 shape is the one they all
draw large. On X the picture is the whole card — since 2024 it shows nothing
but the image, a tiny title overlay and the domain — so the card carries the
words. The card's few words (followers, member since, the date) are in the
words. The card's few words (a profile's follower count, a post's date) are in the
member's own locale, one file for everybody who shares the link. The typeface
is the first of a family list fontconfig finds (Inter when installed, else the
platform sans), and the line metrics are measured at render time, never
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/mastodon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,8 @@ readable. `Presenter.timestamp/1` is the one owner of that shape — the
notification list used to hold a second copy, which kept second precision after
the other was fixed.

**Every member's `created_at` is 1 April 1970.** The field is required and every app shows it as the day the account joined, and how long somebody has been a member is shown nowhere on vutuv (2026-09-18). Leaving it out is not an option for the reason above: an app that cannot read a date fails or falls back. So all members share one placeholder in Mastodon's shape (`Presenter`'s `@member_created_at`); a page keeps its real date, and the stored `inserted_at` is untouched.

**Advertising a version is a promise about the shape of the API.** This adapter
says it is compatible with 4.4, and a client reads that to decide which
endpoints exist: Ice Cubes therefore calls `GET /api/v2/notifications` (the
Expand Down
22 changes: 16 additions & 6 deletions lib/vutuv/mastodon_api/presenter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ defmodule Vutuv.MastodonApi.Presenter do
alias VutuvWeb.Markdown
alias VutuvWeb.RemoteMediaToken

# Every member's `created_at`. The field is required and every app shows it
# as the day the account joined, and how long somebody has been a member is
# shown nowhere on vutuv (Stefan, 2026-09-18). Leaving it out or sending
# something unparseable is not an option: Ivory and Ice Cubes fail on a date
# they cannot read (see `timestamp/1`). So all members share one placeholder,
# printed through `timestamp/1` like every other time. A page keeps its real
# date.
@member_created_at ~U[1970-04-01 00:00:00Z]

def identity_name(%User{} = user),
do: Identity.display_name(user) <> " (@" <> user.username <> ")"

Expand Down Expand Up @@ -59,7 +68,7 @@ defmodule Vutuv.MastodonApi.Presenter do
acct: user.username,
display_name: Identity.display_name(user),
note: note(user.headline),
created_at: created_at(user, user.id),
created_at: timestamp(@member_created_at),
url: profile_url(user),
avatar: avatar,
header: header,
Expand Down Expand Up @@ -1217,11 +1226,12 @@ defmodule Vutuv.MastodonApi.Presenter do
|> DateTime.to_iso8601()
end

# Not every caller hands over a fully loaded row: `Vutuv.Search` selects the
# few columns a result list needs, so `inserted_at` can be absent. Raising
# over a missing display timestamp would be the wrong trade, and there is a
# better answer than nil — the id is a UUIDv7 and carries its own creation
# time.
# Not every caller hands over a fully loaded row: a query that selects only
# the few columns a list needs leaves `inserted_at` out (search once did this
# for members, whose date is a fixed placeholder now). Raising over a missing
# display timestamp would be the wrong trade, and for a page there is a better
# answer than nil — its id is a UUIDv7 and carries its own creation time, which
# is what `created_at/2` falls back to.
def timestamp(_missing), do: nil

defp created_at(%{inserted_at: at}, _id) when not is_nil(at), do: timestamp(at)
Expand Down
1 change: 0 additions & 1 deletion lib/vutuv_web/agent_docs/markdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,6 @@ defmodule VutuvWeb.AgentDocs.Markdown do
User.desired_workplace_line(doc.desired_workplace_types) &&
"- #{gettext("Preferred workplace")}: #{User.desired_workplace_line(doc.desired_workplace_types)}",
doc.desired_salary && "- " <> User.desired_salary_agent_line(doc.desired_salary),
"- #{gettext("Member since")}: #{doc.member_since}",
fediverse_fact(doc[:fediverse]),
count_facts(doc.counts),
birthday_facts(doc)
Expand Down
1 change: 0 additions & 1 deletion lib/vutuv_web/agent_docs/profile_doc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ defmodule VutuvWeb.AgentDocs.ProfileDoc do
headline_markdown: user.headline,
work_info: work_info,
current_position: current_position(job),
member_since: NaiveDateTime.to_date(user.inserted_at),
avatar_url: avatar_url(user),
counts: profile_counts(user, viewer),
tags: Enum.map(user.user_tags, &SectionDocs.tag_entry/1),
Expand Down
1 change: 0 additions & 1 deletion lib/vutuv_web/agent_docs/text.ex
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,6 @@ defmodule VutuvWeb.AgentDocs.Text do
User.desired_workplace_line(doc.desired_workplace_types) &&
"#{gettext("Preferred workplace")}: #{User.desired_workplace_line(doc.desired_workplace_types)}",
doc.desired_salary && User.desired_salary_agent_line(doc.desired_salary),
"#{gettext("Member since")}: #{doc.member_since}",
fediverse_fact(doc[:fediverse]),
count_facts(doc.counts),
birthday_facts(doc)
Expand Down
21 changes: 9 additions & 12 deletions lib/vutuv_web/controllers/og_image_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ defmodule VutuvWeb.OgImageController do
alias VutuvWeb.PostTeaser
alias VutuvWeb.UI
alias VutuvWeb.UserHelpers
alias VutuvWeb.UserHTML

# How many tags the profile card offers the pill rows; the renderer keeps
# as many as fit two rows.
Expand All @@ -47,7 +46,7 @@ defmodule VutuvWeb.OgImageController do
def profile(conn, %{"slug" => slug}) do
png =
with %User{} = user <- public_member(slug) do
in_locale(user, fn -> OgImage.profile_png(profile_data(user)) end)
in_locale(user, fn -> OgImage.profile_png(profile_card_data(user)) end)
end

ControllerHelpers.send_og_image(conn, png, "image/png")
Expand Down Expand Up @@ -120,8 +119,14 @@ defmodule VutuvWeb.OgImageController do
end
end

defp profile_data(%User{} = user) do
Map.merge(author_fields(user), %{tags: tags(user), meta: profile_meta(user)})
@doc false
def profile_card_data(%User{} = user) do
# The follower count and nothing else: how long somebody has been a member
# is shown nowhere, so the join year this line once carried is gone.
Map.merge(author_fields(user), %{
tags: tags(user),
meta: OpenGraph.follower_detail(Social.follower_count(user))
})
end

defp post_data(%Post{} = post, %User{} = author) do
Expand Down Expand Up @@ -159,14 +164,6 @@ defmodule VutuvWeb.OgImageController do
end
end

# "7 followers · Member since 2016"; each half only when it has something to
# say.
defp profile_meta(%User{} = user) do
[OpenGraph.follower_detail(Social.follower_count(user)), UserHTML.member_since(user)]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" · ")
end

# The profile's address as a reader would type it: host and handle, no
# scheme — `www.` included when the installation serves under it.
defp address(%User{username: username}), do: "#{VutuvWeb.Endpoint.host()}/#{username}"
Expand Down
19 changes: 13 additions & 6 deletions lib/vutuv_web/fediverse/docs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,16 @@ defmodule VutuvWeb.Fediverse.Docs do
@doc "The same address the way it is written for humans: `@member@vutuv.de`."
def handle(user), do: "@" <> acct(user)

@doc "The Person document WebFinger points at."
@doc """
The Person document WebFinger points at.

It carries no `published`: Mastodon shows an actor's `published` as the day
the account joined, and how long somebody has been a member is shown nowhere
on vutuv (2026-09-18). A page and a topic keep theirs.
"""
def actor(user, %Actor{} = actor) do
user
|> base_actor(actor, user.inserted_at)
|> base_actor(actor)
|> Map.merge(%{
"type" => Vutuv.Identity.ap_type(user),
"preferredUsername" => user.username,
Expand Down Expand Up @@ -214,8 +220,9 @@ defmodule VutuvWeb.Fediverse.Docs do
"""
def tag_actor(%Tag{} = tag, %Actor{} = actor) do
tag
|> base_actor(actor, tag.inserted_at)
|> base_actor(actor)
|> Map.merge(%{
"published" => iso8601(tag.inserted_at),
"type" => "Group",
"preferredUsername" => tag.slug,
"name" => tag.name,
Expand All @@ -235,7 +242,7 @@ defmodule VutuvWeb.Fediverse.Docs do
# inbox — were among the copies. The per-kind builders stay separate on
# purpose, because half of the member document has no meaning for a page or a
# topic; only this half is shared.
defp base_actor(subject, %Actor{} = actor, published_at) do
defp base_actor(subject, %Actor{} = actor) do
actor_url = actor_url(subject)

%{
Expand All @@ -249,7 +256,6 @@ defmodule VutuvWeb.Fediverse.Docs do
"followers" => followers_url(subject),
"endpoints" => %{"sharedInbox" => shared_inbox_url()},
"manuallyApprovesFollowers" => false,
"published" => iso8601(published_at),
"publicKey" => %{
"id" => key_id(subject),
"owner" => actor_url,
Expand Down Expand Up @@ -290,8 +296,9 @@ defmodule VutuvWeb.Fediverse.Docs do
"""
def organization_actor(%Organization{} = organization, %Actor{} = actor) do
organization
|> base_actor(actor, organization.inserted_at)
|> base_actor(actor)
|> Map.merge(%{
"published" => iso8601(organization.inserted_at),
"type" => Vutuv.Identity.ap_type(organization),
# The page's claimed handle. Federating without one is not possible —
# WebFinger's `subject` and this field both need an address — which is why
Expand Down
7 changes: 4 additions & 3 deletions lib/vutuv_web/views/json_ld.ex
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,17 @@ defmodule VutuvWeb.JsonLd do
enrich the Person entity with the cards the page shows, and the verified
entries of `:urls` (the member proved the page is theirs,
`Vutuv.Profiles.LinkVerification`) join `sameAs` as identity links.
`dateCreated` / `dateModified` + the interaction counters are the fields
search engines document for profile-page markup.
`dateModified` + the interaction counters are the fields search engines
document for profile-page markup. `dateCreated` is one of them too and is
left out on purpose: it would say how long the member has been here, which
vutuv shows nowhere.
"""
def person(user, job, user_tags, social_accounts, extras \\ %{}) do
url = AgentDocs.abs_url("/" <> user.username)

%{
"@context" => "https://schema.org",
"@type" => "ProfilePage",
"dateCreated" => iso_utc(user.inserted_at),
"dateModified" => iso_utc(user.updated_at),
"mainEntity" =>
compact(%{
Expand Down
23 changes: 0 additions & 23 deletions lib/vutuv_web/views/user_html.ex
Original file line number Diff line number Diff line change
Expand Up @@ -277,29 +277,6 @@ defmodule VutuvWeb.UserHTML do
end
end

@doc """
How long the account has been on vutuv, derived from `inserted_at`.

"Member since 2008" for an older account; "Member since February 2026" when
the account was created in the current year, where a bare year reads oddly
for a fresh profile so the month is spelled out. Follows the viewer's locale
via gettext. Returns nil for an unsaved struct with no `inserted_at`.
"""
def member_since(%Vutuv.Accounts.User{inserted_at: %NaiveDateTime{} = inserted_at}) do
joined = NaiveDateTime.to_date(inserted_at)

if joined.year == Vutuv.BerlinTime.today().year do
gettext("Member since %{month} %{year}",
month: month_name(joined.month),
year: joined.year
)
else
gettext("Member since %{year}", year: joined.year)
end
end

def member_since(_user), do: nil

@doc """
The profile header's picture, and the click that opens it at full size
(issue #1528).
Expand Down
1 change: 0 additions & 1 deletion priv/dev_docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ The central entity. A member (`type: "profile"` in API responses) has:
**Honor these**: if your app feeds profiles into an LLM, skip members
with `"noai?": true`. (The public `.json`/`.md` pages carry the same
choice as `Content-Signal` and `X-Robots-Tag` response headers.)
* **`member_since`:** the registration date.

A member who never verified their email address, or who is currently
moderated, is invisible through the API — see
Expand Down
16 changes: 0 additions & 16 deletions priv/gettext/de/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -2076,16 +2076,6 @@ msgstr "März"
msgid "May"
msgstr "Mai"

#: lib/vutuv_web/views/user_html.ex:292
#, elixir-autogen, elixir-format
msgid "Member since %{month} %{year}"
msgstr "Mitglied seit %{month} %{year}"

#: lib/vutuv_web/views/user_html.ex:297
#, elixir-autogen, elixir-format
msgid "Member since %{year}"
msgstr "Mitglied seit %{year}"

#: lib/vutuv_web/components/ui.ex:3753
#, elixir-autogen, elixir-format
msgid "November"
Expand Down Expand Up @@ -3978,12 +3968,6 @@ msgstr "Antwort auf einen gelöschten Beitrag."
msgid "In reply to a post by %{name}."
msgstr "Antwort auf einen Beitrag von %{name}."

#: lib/vutuv_web/agent_docs/markdown.ex:674
#: lib/vutuv_web/agent_docs/text.ex:643
#, elixir-autogen, elixir-format
msgid "Member since"
msgstr "Mitglied seit"

#: lib/vutuv_web/agent_docs/markdown.ex:256
#: lib/vutuv_web/agent_docs/text.ex:218
#, elixir-autogen, elixir-format
Expand Down
16 changes: 0 additions & 16 deletions priv/gettext/default.pot
Original file line number Diff line number Diff line change
Expand Up @@ -2038,16 +2038,6 @@ msgstr ""
msgid "May"
msgstr ""

#: lib/vutuv_web/views/user_html.ex:292
#, elixir-autogen, elixir-format
msgid "Member since %{month} %{year}"
msgstr ""

#: lib/vutuv_web/views/user_html.ex:297
#, elixir-autogen, elixir-format
msgid "Member since %{year}"
msgstr ""

#: lib/vutuv_web/components/ui.ex:3753
#, elixir-autogen, elixir-format
msgid "November"
Expand Down Expand Up @@ -3863,12 +3853,6 @@ msgstr ""
msgid "In reply to a post by %{name}."
msgstr ""

#: lib/vutuv_web/agent_docs/markdown.ex:674
#: lib/vutuv_web/agent_docs/text.ex:643
#, elixir-autogen, elixir-format
msgid "Member since"
msgstr ""

#: lib/vutuv_web/agent_docs/markdown.ex:256
#: lib/vutuv_web/agent_docs/text.ex:218
#, elixir-autogen, elixir-format
Expand Down
16 changes: 0 additions & 16 deletions priv/gettext/en/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -2036,16 +2036,6 @@ msgstr ""
msgid "May"
msgstr ""

#: lib/vutuv_web/views/user_html.ex:292
#, elixir-autogen, elixir-format
msgid "Member since %{month} %{year}"
msgstr ""

#: lib/vutuv_web/views/user_html.ex:297
#, elixir-autogen, elixir-format
msgid "Member since %{year}"
msgstr ""

#: lib/vutuv_web/components/ui.ex:3753
#, elixir-autogen, elixir-format
msgid "November"
Expand Down Expand Up @@ -3861,12 +3851,6 @@ msgstr ""
msgid "In reply to a post by %{name}."
msgstr ""

#: lib/vutuv_web/agent_docs/markdown.ex:674
#: lib/vutuv_web/agent_docs/text.ex:643
#, elixir-autogen, elixir-format
msgid "Member since"
msgstr ""

#: lib/vutuv_web/agent_docs/markdown.ex:256
#: lib/vutuv_web/agent_docs/text.ex:218
#, elixir-autogen, elixir-format
Expand Down
16 changes: 0 additions & 16 deletions priv/gettext/it/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -2076,16 +2076,6 @@ msgstr "Marzo"
msgid "May"
msgstr "Maggio"

#: lib/vutuv_web/views/user_html.ex:292
#, elixir-autogen, elixir-format
msgid "Member since %{month} %{year}"
msgstr "Membro da %{month} %{year}"

#: lib/vutuv_web/views/user_html.ex:297
#, elixir-autogen, elixir-format
msgid "Member since %{year}"
msgstr "Membro dal %{year}"

#: lib/vutuv_web/components/ui.ex:3753
#, elixir-autogen, elixir-format
msgid "November"
Expand Down Expand Up @@ -3985,12 +3975,6 @@ msgstr "In risposta a un post eliminato."
msgid "In reply to a post by %{name}."
msgstr "In risposta a un post di %{name}."

#: lib/vutuv_web/agent_docs/markdown.ex:674
#: lib/vutuv_web/agent_docs/text.ex:643
#, elixir-autogen, elixir-format
msgid "Member since"
msgstr "Membro da"

#: lib/vutuv_web/agent_docs/markdown.ex:256
#: lib/vutuv_web/agent_docs/text.ex:218
#, elixir-autogen, elixir-format
Expand Down
Loading
Loading