diff --git a/docs/architecture/agents-and-seo.md b/docs/architecture/agents-and-seo.md index 03ce636dd..46dea3dd6 100644 --- a/docs/architecture/agents-and-seo.md +++ b/docs/architecture/agents-and-seo.md @@ -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, @@ -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 diff --git a/docs/architecture/mastodon-api.md b/docs/architecture/mastodon-api.md index 1ebdf33da..70fc55109 100644 --- a/docs/architecture/mastodon-api.md +++ b/docs/architecture/mastodon-api.md @@ -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 diff --git a/lib/vutuv/mastodon_api/presenter.ex b/lib/vutuv/mastodon_api/presenter.ex index c61cb20cc..ebc23886a 100644 --- a/lib/vutuv/mastodon_api/presenter.ex +++ b/lib/vutuv/mastodon_api/presenter.ex @@ -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 <> ")" @@ -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, @@ -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) diff --git a/lib/vutuv_web/agent_docs/markdown.ex b/lib/vutuv_web/agent_docs/markdown.ex index d47759773..422868956 100644 --- a/lib/vutuv_web/agent_docs/markdown.ex +++ b/lib/vutuv_web/agent_docs/markdown.ex @@ -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) diff --git a/lib/vutuv_web/agent_docs/profile_doc.ex b/lib/vutuv_web/agent_docs/profile_doc.ex index 73bf72fec..ed3ed306f 100644 --- a/lib/vutuv_web/agent_docs/profile_doc.ex +++ b/lib/vutuv_web/agent_docs/profile_doc.ex @@ -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), diff --git a/lib/vutuv_web/agent_docs/text.ex b/lib/vutuv_web/agent_docs/text.ex index 033161642..e61738a29 100644 --- a/lib/vutuv_web/agent_docs/text.ex +++ b/lib/vutuv_web/agent_docs/text.ex @@ -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) diff --git a/lib/vutuv_web/controllers/og_image_controller.ex b/lib/vutuv_web/controllers/og_image_controller.ex index b1b3d83e6..93842fda1 100644 --- a/lib/vutuv_web/controllers/og_image_controller.ex +++ b/lib/vutuv_web/controllers/og_image_controller.ex @@ -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. @@ -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") @@ -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 @@ -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}" diff --git a/lib/vutuv_web/fediverse/docs.ex b/lib/vutuv_web/fediverse/docs.ex index fb78a0ece..910e87da5 100644 --- a/lib/vutuv_web/fediverse/docs.ex +++ b/lib/vutuv_web/fediverse/docs.ex @@ -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, @@ -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, @@ -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) %{ @@ -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, @@ -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 diff --git a/lib/vutuv_web/views/json_ld.ex b/lib/vutuv_web/views/json_ld.ex index 90180cbe4..7c1281cc6 100644 --- a/lib/vutuv_web/views/json_ld.ex +++ b/lib/vutuv_web/views/json_ld.ex @@ -139,8 +139,10 @@ 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) @@ -148,7 +150,6 @@ defmodule VutuvWeb.JsonLd do %{ "@context" => "https://schema.org", "@type" => "ProfilePage", - "dateCreated" => iso_utc(user.inserted_at), "dateModified" => iso_utc(user.updated_at), "mainEntity" => compact(%{ diff --git a/lib/vutuv_web/views/user_html.ex b/lib/vutuv_web/views/user_html.ex index bac2da432..447471c4f 100644 --- a/lib/vutuv_web/views/user_html.ex +++ b/lib/vutuv_web/views/user_html.ex @@ -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). diff --git a/priv/dev_docs/data-model.md b/priv/dev_docs/data-model.md index 3536404df..101657b06 100644 --- a/priv/dev_docs/data-model.md +++ b/priv/dev_docs/data-model.md @@ -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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 685dd6b51..f0e8de192 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -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" @@ -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 diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 9abb5641c..2fbcea276 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -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" @@ -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 diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a2c0c39e9..834ef744a 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -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" @@ -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 diff --git a/priv/gettext/it/LC_MESSAGES/default.po b/priv/gettext/it/LC_MESSAGES/default.po index 47768b481..a675671b2 100644 --- a/priv/gettext/it/LC_MESSAGES/default.po +++ b/priv/gettext/it/LC_MESSAGES/default.po @@ -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" @@ -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 diff --git a/test/vutuv_web/agent_docs/agent_docs_drift_test.exs b/test/vutuv_web/agent_docs/agent_docs_drift_test.exs index 64cb35503..11135623b 100644 --- a/test/vutuv_web/agent_docs/agent_docs_drift_test.exs +++ b/test/vutuv_web/agent_docs/agent_docs_drift_test.exs @@ -1456,18 +1456,18 @@ defmodule VutuvWeb.AgentDocsDriftTest do test "?lang=de translates the labels, English stays the default" do de_txt = get(build_conn(), "/drift_tester.txt?lang=de").resp_body - assert de_txt =~ "Mitglied seit:" + assert de_txt =~ "Vorname:" assert de_txt =~ "TAGS" de_md = get(build_conn(), "/drift_tester.md?lang=de").resp_body assert de_md =~ "## Lebenslauf" en_txt = get(build_conn(), "/drift_tester.txt").resp_body - assert en_txt =~ "Member since:" + assert en_txt =~ "First Name:" # An unknown language falls back to English instead of erroring. fallback = get(build_conn(), "/drift_tester.txt?lang=xx").resp_body - assert fallback =~ "Member since:" + assert fallback =~ "First Name:" end test "a linked work experience carries its verified organization page in every format" do diff --git a/test/vutuv_web/controllers/user_controller_test.exs b/test/vutuv_web/controllers/user_controller_test.exs index 6338deb70..82fc73553 100644 --- a/test/vutuv_web/controllers/user_controller_test.exs +++ b/test/vutuv_web/controllers/user_controller_test.exs @@ -141,24 +141,6 @@ defmodule VutuvWeb.UserControllerTest do assert html_response(conn, 200) =~ user.first_name end - test "profile header leaves out the join date", %{conn: conn} do - # The join date said little and cost the header card a line on a phone, so - # the card no longer shows it, in any locale. The agent formats keep it as - # a fact, where it costs no room. - user = insert_activated_user(inserted_at: ~N[2008-02-15 10:00:00]) - - html = conn |> get(~p"/#{user}") |> html_response(200) - refute html =~ "Member since" - - de_html = - build_conn() - |> put_req_header("accept-language", "de-DE,de") - |> get(~p"/#{user}") - |> html_response(200) - - refute de_html =~ "Mitglied seit" - end - test "profile hides a zero follower/following counter", %{conn: conn} do # One follower, nobody followed back: the followers counter shows, the # following counter is gone (a bare "0 following" says nothing). diff --git a/test/vutuv_web/join_date_hidden_test.exs b/test/vutuv_web/join_date_hidden_test.exs new file mode 100644 index 000000000..124474f73 --- /dev/null +++ b/test/vutuv_web/join_date_hidden_test.exs @@ -0,0 +1,82 @@ +defmodule VutuvWeb.JoinDateHiddenTest do + @moduledoc """ + Nothing a reader, an agent or another server sees says how long somebody has + been a member (Stefan, 2026-09-18). The date itself stays in the database — + the onboarding window, the sweepers and the admin views read it — only its + display is gone, so this module pins every public surface that used to show + it: the profile page, its share card, its agent formats, its schema.org + block, its fediverse actor and the Mastodon API's account entity. + """ + use VutuvWeb.ConnCase, async: true + + import Vutuv.MastodonHelpers + + alias Vutuv.Fediverse + alias VutuvWeb.Fediverse.Docs + alias VutuvWeb.OgImageController + + @joined ~N[2008-02-15 10:00:00] + + setup do + %{member: insert_activated_user(inserted_at: @joined)} + end + + test "the profile page, in English and in German", %{conn: conn, member: member} do + html = conn |> get(~p"/#{member}") |> html_response(200) + refute html =~ "Member since" + + de_html = + build_conn() + |> put_req_header("accept-language", "de-DE,de") + |> get(~p"/#{member}") + |> html_response(200) + + refute de_html =~ "Mitglied seit" + end + + test "the share card says followers and nothing about the join year", %{member: member} do + follow!(insert(:activated_user), member) + + meta = OgImageController.profile_card_data(member).meta + + assert meta =~ "1" + refute meta =~ "2008" + end + + test "no agent format carries the join date", %{conn: conn, member: member} do + for ext <- ~w(md txt json xml), lang <- ~w(en de) do + body = conn |> get("/#{member.username}.#{ext}?lang=#{lang}") |> response(200) + + refute body =~ "2008-02-15", "/#{member.username}.#{ext}?lang=#{lang} states the join date" + refute body =~ "member_since" + refute body =~ "Member since" + refute body =~ "Mitglied seit" + end + end + + test "the page's schema.org block has no creation date", %{conn: conn, member: member} do + page = conn |> get(~p"/#{member}") |> html_response(200) |> json_ld("ProfilePage") + + assert page, "the profile still describes itself as a ProfilePage" + refute Map.has_key?(page, "dateCreated") + end + + test "the fediverse actor has no published date", %{member: member} do + {:ok, actor} = Fediverse.ensure_actor(member) + + refute Map.has_key?(Docs.actor(member, actor), "published"), + "Mastodon shows an actor's `published` as the date the account joined." + end + + test "a Mastodon app is told 1 April 1970 for every member", %{conn: conn, member: member} do + account = + conn + |> on_mastodon_host() + |> get("/api/v1/accounts/lookup", %{"acct" => member.username}) + |> json_response(200) + + assert account["created_at"] == "1970-04-01T00:00:00.000Z", + "`created_at` is required and apps show it as the join date, so every " <> + "member gets the same placeholder in Mastodon's own shape." + end +end diff --git a/test/vutuv_web/json_ld_test.exs b/test/vutuv_web/json_ld_test.exs index 604984769..eb460bb80 100644 --- a/test/vutuv_web/json_ld_test.exs +++ b/test/vutuv_web/json_ld_test.exs @@ -84,11 +84,6 @@ defmodule VutuvWeb.JsonLdTest do db_user = Vutuv.Repo.get!(Vutuv.Accounts.User, user.id) - assert page["dateCreated"] == - db_user.inserted_at - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_iso8601() - assert page["dateModified"] == db_user.updated_at |> DateTime.from_naive!("Etc/UTC") diff --git a/test/vutuv_web/og_image_test.exs b/test/vutuv_web/og_image_test.exs index 30117527a..aec7574a3 100644 --- a/test/vutuv_web/og_image_test.exs +++ b/test/vutuv_web/og_image_test.exs @@ -25,7 +25,7 @@ defmodule VutuvWeb.OgImageTest do name: "Greta Tester", headline: "Developer @ Acme Corp", tags: ["Elixir", "Phoenix", "open source", "PostgreSQL", "Rust", "Kubernetes", "Go"], - meta: "12 followers · Member since 2016", + meta: "12 followers", footer: "vutuv.de/greta" } diff --git a/test/vutuv_web/views/user_html_test.exs b/test/vutuv_web/views/user_html_test.exs index 5d5997d16..c1526d215 100644 --- a/test/vutuv_web/views/user_html_test.exs +++ b/test/vutuv_web/views/user_html_test.exs @@ -1,50 +1,9 @@ defmodule VutuvWeb.UserHTMLTest do @moduledoc """ - Covers the "Member since" label on the profile's share card: just the - year for older accounts, the spelled-out month for accounts created in the - current year (where a bare year would read oddly for a fresh profile). + Pure helpers of `VutuvWeb.UserHTML` that need no database. """ use ExUnit.Case, async: true - alias Vutuv.Accounts.User - - describe "member_since/1" do - test "shows just the year for accounts created in a past year" do - user = %User{inserted_at: ~N[2008-02-15 10:00:00]} - assert VutuvWeb.UserHTML.member_since(user) == "Member since 2008" - end - - test "spells out the month for accounts created in the current year" do - today = Date.utc_today() - user = %User{inserted_at: NaiveDateTime.new!(today.year, today.month, 1, 12, 0, 0)} - month = Calendar.strftime(today, "%B") - - assert VutuvWeb.UserHTML.member_since(user) == "Member since #{month} #{today.year}" - end - - test "returns nil when the account has no inserted_at yet" do - assert VutuvWeb.UserHTML.member_since(%User{}) == nil - end - - test "follows the viewer's locale (German)" do - Gettext.put_locale(VutuvWeb.Gettext, "de") - - assert VutuvWeb.UserHTML.member_since(%User{inserted_at: ~N[2008-02-15 10:00:00]}) == - "Mitglied seit 2008" - - today = Date.utc_today() - user = %User{inserted_at: NaiveDateTime.new!(today.year, today.month, 1, 12, 0, 0)} - - de_month = - Enum.at( - ~w(Januar Februar März April Mai Juni Juli August September Oktober November Dezember), - today.month - 1 - ) - - assert VutuvWeb.UserHTML.member_since(user) == "Mitglied seit #{de_month} #{today.year}" - end - end - describe "compact_activity_date/2 (Code card last-active date)" do test "shows only the year for a date in an earlier year" do assert VutuvWeb.UserHTML.compact_activity_date(~D[2025-05-28], ~D[2026-07-09]) == "2025"