X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fpleroma%2Fuser.ex;h=892f4e483be6e63ced3403abec02befc931a3a65;hb=6c8d15da110e86f799052c82df8b7b2404f8f722;hp=a290db04adb91135c321c6ffec2a70e17da9c192;hpb=4976a8b05f359cd32b37612727f81c89df85d651;p=akkoma diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a290db04a..0060d966b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1,14 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.User do use Ecto.Schema - import Ecto.{Changeset, Query} - alias Ecto.Multi - alias Pleroma.{Repo, User, Object, Web, Activity, Notification} + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Object + alias Pleroma.Web + alias Pleroma.Activity + alias Pleroma.Notification alias Comeonin.Pbkdf2 alias Pleroma.Formatter alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils - alias Pleroma.Web.{OStatus, Websub, OAuth} - alias Pleroma.Web.ActivityPub.{Utils, ActivityPub} + alias Pleroma.Web.OStatus + alias Pleroma.Web.Websub + alias Pleroma.Web.OAuth + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.ActivityPub + + require Logger + + @type t :: %__MODULE__{} + + @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + + @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + + @strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/ + @extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/ schema "users" do field(:bio, :string) @@ -23,8 +47,9 @@ defmodule Pleroma.User do field(:avatar, :map) field(:local, :boolean, default: true) field(:follower_address, :string) - field(:search_distance, :float, virtual: true) + field(:search_rank, :float, virtual: true) field(:tags, {:array, :string}, default: []) + field(:bookmarks, {:array, :string}, default: []) field(:last_refreshed_at, :naive_datetime) has_many(:notifications, Notification) embeds_one(:info, Pleroma.User.Info) @@ -32,6 +57,29 @@ defmodule Pleroma.User do timestamps() end + def auth_active?(%User{local: false}), do: true + + def auth_active?(%User{info: %User.Info{confirmation_pending: false}}), do: true + + def auth_active?(%User{info: %User.Info{confirmation_pending: true}}), + do: !Pleroma.Config.get([:instance, :account_activation_required]) + + def auth_active?(_), do: false + + def visible_for?(user, for_user \\ nil) + + def visible_for?(%User{id: user_id}, %User{id: for_id}) when user_id == for_id, do: true + + def visible_for?(%User{} = user, for_user) do + auth_active?(user) || superuser?(for_user) + end + + def visible_for?(_, _), do: false + + def superuser?(%User{local: true, info: %User.Info{is_admin: true}}), do: true + def superuser?(%User{local: true, info: %User.Info{is_moderator: true}}), do: true + def superuser?(_), do: false + def avatar_url(user) do case user.avatar do %{"url" => [%{"href" => href} | _]} -> href @@ -58,12 +106,6 @@ defmodule Pleroma.User do "#{ap_id(user)}/followers" end - def follow_changeset(struct, params \\ %{}) do - struct - |> cast(params, [:following]) - |> validate_required([:following]) - end - def user_info(%User{} = user) do oneself = if user.local, do: 1, else: 0 @@ -72,11 +114,11 @@ defmodule Pleroma.User do note_count: user.info.note_count, follower_count: user.info.follower_count, locked: user.info.locked, + confirmation_pending: user.info.confirmation_pending, default_scope: user.info.default_scope } end - @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ def remote_user_creation(params) do params = params @@ -116,7 +158,7 @@ defmodule Pleroma.User do struct |> cast(params, [:bio, :name, :avatar]) |> unique_constraint(:nickname) - |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/) + |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: 5000) |> validate_length(:name, min: 1, max: 100) end @@ -133,7 +175,7 @@ defmodule Pleroma.User do struct |> cast(params, [:bio, :name, :follower_address, :avatar, :last_refreshed_at]) |> unique_constraint(:nickname) - |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/) + |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: 5000) |> validate_length(:name, max: 100) |> put_embed(:info, info_cng) @@ -163,7 +205,16 @@ defmodule Pleroma.User do update_and_set_cache(password_update_changeset(user, data)) end - def register_changeset(struct, params \\ %{}) do + def register_changeset(struct, params \\ %{}, opts \\ []) do + confirmation_status = + if opts[:confirmed] || !Pleroma.Config.get([:instance, :account_activation_required]) do + :confirmed + else + :unconfirmed + end + + info_change = User.Info.confirmation_changeset(%User.Info{}, confirmation_status) + changeset = struct |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) @@ -171,11 +222,12 @@ defmodule Pleroma.User do |> validate_confirmation(:password) |> unique_constraint(:email) |> unique_constraint(:nickname) - |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/) + |> validate_exclusion(:nickname, Pleroma.Config.get([Pleroma.User, :restricted_nicknames])) + |> validate_format(:nickname, local_nickname_regex()) |> validate_format(:email, @email_regex) |> validate_length(:bio, max: 1000) |> validate_length(:name, min: 1, max: 100) - |> put_change(:info, %Pleroma.User.Info{}) + |> put_change(:info, info_change) if changeset.valid? do hashed = Pbkdf2.hashpwsalt(changeset.changes[:password]) @@ -192,6 +244,39 @@ defmodule Pleroma.User do end end + defp autofollow_users(user) do + candidates = Pleroma.Config.get([:instance, :autofollowed_nicknames]) + + autofollowed_users = + from(u in User, + where: u.local == true, + where: u.nickname in ^candidates + ) + |> Repo.all() + + follow_all(user, autofollowed_users) + end + + @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" + def register(%Ecto.Changeset{} = changeset) do + with {:ok, user} <- Repo.insert(changeset), + {:ok, user} <- autofollow_users(user), + {:ok, _} <- try_send_confirmation_email(user) do + {:ok, user} + end + end + + def try_send_confirmation_email(%User{} = user) do + if user.info.confirmation_pending && + Pleroma.Config.get([:instance, :account_activation_required]) do + user + |> Pleroma.UserEmail.account_confirmation_email() + |> Pleroma.Mailer.deliver() + else + {:ok, :noop} + end + end + def needs_update?(%User{local: true}), do: false def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true @@ -211,14 +296,14 @@ defmodule Pleroma.User do end def maybe_direct_follow(%User{} = follower, %User{} = followed) do - if !User.ap_enabled?(followed) do + if not User.ap_enabled?(followed) do follow(follower, followed) else {:ok, follower} end end - def maybe_follow(%User{} = follower, %User{info: info} = followed) do + def maybe_follow(%User{} = follower, %User{info: _info} = followed) do if not following?(follower, followed) do follow(follower, followed) else @@ -226,6 +311,38 @@ defmodule Pleroma.User do end end + @doc "A mass follow for local users. Respects blocks but does not create activities." + @spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()} + def follow_all(follower, followeds) do + followed_addresses = + followeds + |> Enum.reject(fn %{ap_id: ap_id} -> ap_id in follower.info.blocks end) + |> Enum.map(fn %{follower_address: fa} -> fa end) + + q = + from(u in User, + where: u.id == ^follower.id, + update: [ + set: [ + following: + fragment( + "array(select distinct unnest (array_cat(?, ?)))", + u.following, + ^followed_addresses + ) + ] + ] + ) + + {1, [follower]} = Repo.update_all(q, [], returning: true) + + Enum.each(followeds, fn followed -> + update_follower_count(followed) + end) + + set_cache(follower) + end + def follow(%User{} = follower, %User{info: info} = followed) do user_config = Application.get_env(:pleroma, :user) deny_follow_blocked = Keyword.get(user_config, :deny_follow_blocked) @@ -244,18 +361,17 @@ defmodule Pleroma.User do Websub.subscribe(follower, followed) end - following = - [ap_followers | follower.following] - |> Enum.uniq() + q = + from(u in User, + where: u.id == ^follower.id, + update: [push: [following: ^ap_followers]] + ) - follower = - follower - |> follow_changeset(%{following: following}) - |> update_and_set_cache + {1, [follower]} = Repo.update_all(q, [], returning: true) {:ok, _} = update_follower_count(followed) - follower + set_cache(follower) end end @@ -263,41 +379,78 @@ defmodule Pleroma.User do ap_followers = followed.follower_address if following?(follower, followed) and follower.ap_id != followed.ap_id do - following = - follower.following - |> List.delete(ap_followers) + q = + from(u in User, + where: u.id == ^follower.id, + update: [pull: [following: ^ap_followers]] + ) - {:ok, follower} = - follower - |> follow_changeset(%{following: following}) - |> update_and_set_cache + {1, [follower]} = Repo.update_all(q, [], returning: true) {:ok, followed} = update_follower_count(followed) + set_cache(follower) + {:ok, follower, Utils.fetch_latest_follow(follower, followed)} else {:error, "Not subscribed!"} end end + @spec following?(User.t(), User.t()) :: boolean def following?(%User{} = follower, %User{} = followed) do Enum.member?(follower.following, followed.follower_address) end + def follow_import(%User{} = follower, followed_identifiers) + when is_list(followed_identifiers) do + Enum.map( + followed_identifiers, + fn followed_identifier -> + with %User{} = followed <- get_or_fetch(followed_identifier), + {:ok, follower} <- maybe_direct_follow(follower, followed), + {:ok, _} <- ActivityPub.follow(follower, followed) do + followed + else + err -> + Logger.debug("follow_import failed for #{followed_identifier} with: #{inspect(err)}") + err + end + end + ) + end + def locked?(%User{} = user) do user.info.locked || false end + def get_by_id(id) do + Repo.get_by(User, id: id) + end + def get_by_ap_id(ap_id) do Repo.get_by(User, ap_id: ap_id) end + # This is mostly an SPC migration fix. This guesses the user nickname (by taking the last part of the ap_id and the domain) and tries to get that user + def get_by_guessed_nickname(ap_id) do + domain = URI.parse(ap_id).host + name = List.last(String.split(ap_id, "/")) + nickname = "#{name}@#{domain}" + + get_by_nickname(nickname) + end + + def set_cache(user) do + Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + Cachex.put(:user_cache, "nickname:#{user.nickname}", user) + Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user)) + {:ok, user} + end + def update_and_set_cache(changeset) do with {:ok, user} <- Repo.update(changeset) do - Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) - Cachex.put(:user_cache, "nickname:#{user.nickname}", user) - Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user)) - {:ok, user} + set_cache(user) else e -> e end @@ -314,13 +467,38 @@ defmodule Pleroma.User do Cachex.fetch!(:user_cache, key, fn _ -> get_by_ap_id(ap_id) end) end + def get_cached_by_id(id) do + key = "id:#{id}" + + ap_id = + Cachex.fetch!(:user_cache, key, fn _ -> + user = get_by_id(id) + + if user do + Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + {:commit, user.ap_id} + else + {:ignore, ""} + end + end) + + get_cached_by_ap_id(ap_id) + end + def get_cached_by_nickname(nickname) do key = "nickname:#{nickname}" Cachex.fetch!(:user_cache, key, fn _ -> get_or_fetch_by_nickname(nickname) end) end + def get_cached_by_nickname_or_id(nickname_or_id) do + get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id) + end + def get_by_nickname(nickname) do - Repo.get_by(User, nickname: nickname) + Repo.get_by(User, nickname: nickname) || + if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do + Repo.get_by(User, nickname: local_nickname(nickname)) + end end def get_by_nickname_or_email(nickname_or_email) do @@ -358,7 +536,7 @@ defmodule Pleroma.User do end end - def get_followers_query(%User{id: id, follower_address: follower_address}) do + def get_followers_query(%User{id: id, follower_address: follower_address}, nil) do from( u in User, where: fragment("? <@ ?", ^[follower_address], u.following), @@ -366,13 +544,29 @@ defmodule Pleroma.User do ) end - def get_followers(user) do - q = get_followers_query(user) + def get_followers_query(user, page) do + from( + u in get_followers_query(user, nil), + limit: 20, + offset: ^((page - 1) * 20) + ) + end + + def get_followers_query(user), do: get_followers_query(user, nil) + + def get_followers(user, page \\ nil) do + q = get_followers_query(user, page) {:ok, Repo.all(q)} end - def get_friends_query(%User{id: id, following: following}) do + def get_followers_ids(user, page \\ nil) do + q = get_followers_query(user, page) + + Repo.all(from(u in q, select: u.id)) + end + + def get_friends_query(%User{id: id, following: following}, nil) do from( u in User, where: u.follower_address in ^following, @@ -380,12 +574,28 @@ defmodule Pleroma.User do ) end - def get_friends(user) do - q = get_friends_query(user) + def get_friends_query(user, page) do + from( + u in get_friends_query(user, nil), + limit: 20, + offset: ^((page - 1) * 20) + ) + end + + def get_friends_query(user), do: get_friends_query(user, nil) + + def get_friends(user, page \\ nil) do + q = get_friends_query(user, page) {:ok, Repo.all(q)} end + def get_friends_ids(user, page \\ nil) do + q = get_friends_query(user, page) + + Repo.all(from(u in q, select: u.id)) + end + def get_follow_requests_query(%User{} = user) do from( a in Activity, @@ -416,6 +626,7 @@ defmodule Pleroma.User do Enum.map(reqs, fn req -> req.actor end) |> Enum.uniq() |> Enum.map(fn ap_id -> get_by_ap_id(ap_id) end) + |> Enum.filter(fn u -> !is_nil(u) end) |> Enum.filter(fn u -> !following?(u, user) end) {:ok, users} @@ -516,37 +727,137 @@ defmodule Pleroma.User do Repo.all(query) end - def search(query, resolve \\ false) do - # strip the beginning @ off if there is a query + def search(query, resolve \\ false, for_user \\ nil) do + # Strip the beginning @ off if there is a query query = String.trim_leading(query, "@") - if resolve do - User.get_or_fetch_by_nickname(query) - end + if resolve, do: User.get_or_fetch_by_nickname(query) - inner = - from( - u in User, - select_merge: %{ - search_distance: - fragment( - "? <-> (? || ?)", - ^query, - u.nickname, - u.name - ) - }, - where: not is_nil(u.nickname) - ) + fts_results = do_search(fts_search_subquery(query), for_user) + + {:ok, trigram_results} = + Repo.transaction(fn -> + Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", []) + do_search(trigram_search_subquery(query), for_user) + end) + + Enum.uniq_by(fts_results ++ trigram_results, & &1.id) + end + defp do_search(subquery, for_user, options \\ []) do q = from( - s in subquery(inner), - order_by: s.search_distance, - limit: 20 + s in subquery(subquery), + order_by: [desc: s.search_rank], + limit: ^(options[:limit] || 20) ) - Repo.all(q) + results = + q + |> Repo.all() + |> Enum.filter(&(&1.search_rank > 0)) + + boost_search_results(results, for_user) + end + + defp fts_search_subquery(query) do + processed_query = + query + |> String.replace(~r/\W+/, " ") + |> String.trim() + |> String.split() + |> Enum.map(&(&1 <> ":*")) + |> Enum.join(" | ") + + from( + u in User, + select_merge: %{ + search_rank: + fragment( + """ + ts_rank_cd( + setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'), + to_tsquery('simple', ?), + 32 + ) + """, + u.nickname, + u.name, + ^processed_query + ) + }, + where: + fragment( + """ + (setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?) + """, + u.nickname, + u.name, + ^processed_query + ) + ) + end + + defp trigram_search_subquery(query) do + from( + u in User, + select_merge: %{ + search_rank: + fragment( + "similarity(?, trim(? || ' ' || coalesce(?, '')))", + ^query, + u.nickname, + u.name + ) + }, + where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^query) + ) + end + + defp boost_search_results(results, nil), do: results + + defp boost_search_results(results, for_user) do + friends_ids = get_friends_ids(for_user) + followers_ids = get_followers_ids(for_user) + + Enum.map( + results, + fn u -> + search_rank_coef = + cond do + u.id in friends_ids -> + 1.2 + + u.id in followers_ids -> + 1.1 + + true -> + 1 + end + + Map.put(u, :search_rank, u.search_rank * search_rank_coef) + end + ) + |> Enum.sort_by(&(-&1.search_rank)) + end + + def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers) do + Enum.map( + blocked_identifiers, + fn blocked_identifier -> + with %User{} = blocked <- get_or_fetch(blocked_identifier), + {:ok, blocker} <- block(blocker, blocked), + {:ok, _} <- ActivityPub.block(blocker, blocked) do + blocked + else + err -> + Logger.debug("blocks_import failed for #{blocked_identifier} with: #{inspect(err)}") + err + end + end + ) end def block(blocker, %User{ap_id: ap_id} = blocked) do @@ -602,6 +913,9 @@ defmodule Pleroma.User do end) end + def blocked_users(user), + do: Repo.all(from(u in User, where: u.ap_id in ^user.info.blocks)) + def block_domain(user, domain) do info_cng = user.info @@ -626,7 +940,7 @@ defmodule Pleroma.User do update_and_set_cache(cng) end - def local_user_query() do + def local_user_query do from( u in User, where: u.local == true, @@ -634,7 +948,14 @@ defmodule Pleroma.User do ) end - def moderator_user_query() do + def active_local_user_query do + from( + u in local_user_query(), + where: fragment("not (?->'deactivated' @> 'true')", u.info) + ) + end + + def moderator_user_query do from( u in User, where: u.local == true, @@ -687,7 +1008,9 @@ defmodule Pleroma.User do Pleroma.HTML.Scrubber.TwitterText end - def html_filter_policy(_), do: nil + @default_scrubbers Pleroma.Config.get([:markup, :scrub_policy]) + + def html_filter_policy(_), do: @default_scrubbers def get_or_fetch_by_ap_id(ap_id) do user = get_by_ap_id(ap_id) @@ -734,7 +1057,8 @@ defmodule Pleroma.User do source_data: %{"publicKey" => %{"publicKeyPem" => public_key_pem}} }) do key = - :public_key.pem_decode(public_key_pem) + public_key_pem + |> :public_key.pem_decode() |> hd() |> :public_key.pem_entry_decode() @@ -772,13 +1096,10 @@ defmodule Pleroma.User do def ap_enabled?(%User{info: info}), do: info.ap_enabled def ap_enabled?(_), do: false - def get_or_fetch(uri_or_nickname) do - if String.starts_with?(uri_or_nickname, "http") do - get_or_fetch_by_ap_id(uri_or_nickname) - else - get_or_fetch_by_nickname(uri_or_nickname) - end - end + @doc "Gets or fetch a user by uri or nickname." + @spec get_or_fetch(String.t()) :: User.t() + def get_or_fetch("http" <> _host = uri), do: get_or_fetch_by_ap_id(uri) + def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname) # wait a period of time and return newest version of the User structs # this is because we have synchronous follow APIs and need to simulate them @@ -804,7 +1125,11 @@ defmodule Pleroma.User do end end - def parse_bio(bio, user \\ %User{info: %{source_data: %{}}}) do + def parse_bio(bio, user \\ %User{info: %{source_data: %{}}}) + def parse_bio(nil, _user), do: "" + def parse_bio(bio, _user) when bio == "", do: bio + + def parse_bio(bio, user) do mentions = Formatter.parse_mentions(bio) tags = Formatter.parse_tags(bio) @@ -815,7 +1140,9 @@ defmodule Pleroma.User do {String.trim(name, ":"), url} end) - CommonUtils.format_input(bio, mentions, tags, "text/plain") |> Formatter.emojify(emoji) + bio + |> CommonUtils.format_input(mentions, tags, "text/plain", user_links: [format: :full]) + |> Formatter.emojify(emoji) end def tag(user_identifiers, tags) when is_list(user_identifiers) do @@ -824,21 +1151,23 @@ defmodule Pleroma.User do end) end + def tag(nickname, tags) when is_binary(nickname), + do: tag(User.get_by_nickname(nickname), tags) + + def tag(%User{} = user, tags), + do: update_tags(user, Enum.uniq((user.tags || []) ++ normalize_tags(tags))) + def untag(user_identifiers, tags) when is_list(user_identifiers) do Repo.transaction(fn -> for user_identifier <- user_identifiers, do: untag(user_identifier, tags) end) end - def tag(nickname, tags) when is_binary(nickname), do: tag(User.get_by_nickname(nickname), tags) - def untag(nickname, tags) when is_binary(nickname), do: untag(User.get_by_nickname(nickname), tags) - def tag(%User{} = user, tags), - do: update_tags(user, Enum.uniq(user.tags ++ normalize_tags(tags))) - - def untag(%User{} = user, tags), do: update_tags(user, user.tags -- normalize_tags(tags)) + def untag(%User{} = user, tags), + do: update_tags(user, (user.tags || []) -- normalize_tags(tags)) defp update_tags(%User{} = user, new_tags) do {:ok, updated_user} = @@ -849,9 +1178,53 @@ defmodule Pleroma.User do updated_user end + def bookmark(%User{} = user, status_id) do + bookmarks = Enum.uniq(user.bookmarks ++ [status_id]) + update_bookmarks(user, bookmarks) + end + + def unbookmark(%User{} = user, status_id) do + bookmarks = Enum.uniq(user.bookmarks -- [status_id]) + update_bookmarks(user, bookmarks) + end + + def update_bookmarks(%User{} = user, bookmarks) do + user + |> change(%{bookmarks: bookmarks}) + |> update_and_set_cache + end + defp normalize_tags(tags) do [tags] |> List.flatten() |> Enum.map(&String.downcase(&1)) end + + defp local_nickname_regex() do + if Pleroma.Config.get([:instance, :extended_nickname_format]) do + @extended_local_nickname_regex + else + @strict_local_nickname_regex + end + end + + def local_nickname(nickname_or_mention) do + nickname_or_mention + |> full_nickname() + |> String.split("@") + |> hd() + end + + def full_nickname(nickname_or_mention), + do: String.trim_leading(nickname_or_mention, "@") + + def error_user(ap_id) do + %User{ + name: ap_id, + ap_id: ap_id, + info: %User.Info{}, + nickname: "erroruser@example.com", + inserted_at: NaiveDateTime.utc_now() + } + end end