From 71f5a493f3be15308959a77ad368a61de48721e2 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 1 Jan 2026 10:02:37 +0400 Subject: [PATCH] Search: Better sorting for user searches. --- lib/pleroma/user/search.ex | 48 +++++++- test/pleroma/user/search_test.exs | 195 ++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 test/pleroma/user/search_test.exs diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 851745714..abc45f62a 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -4,6 +4,7 @@ defmodule Pleroma.User.Search do alias Pleroma.EctoType.ActivityPub.ObjectValidators.Uri, as: UriType + alias Pleroma.Instances.Instance alias Pleroma.Pagination alias Pleroma.User @@ -88,12 +89,13 @@ defmodule Pleroma.User.Search do |> filter_invisible_users() |> filter_internal_users() |> filter_blocked_domains(for_user) + |> filter_unreachable_users() |> fts_search(query_string) |> select_top_users(top_user_ids) |> trigram_rank(query_string) |> boost_search_rank(for_user, top_user_ids) |> subquery() - |> order_by(desc: :search_rank) + |> order_by_search_rank(for_user) |> maybe_restrict_local(for_user) |> maybe_restrict_accepting_chat_messages(capabilities) |> filter_deactivated_users() @@ -196,6 +198,14 @@ defmodule Pleroma.User.Search do defp filter_blocked_domains(query, _), do: query + defp filter_unreachable_users(query) do + from(u in query, + left_join: i in Instance, + on: i.host == fragment("substring(? from '.*://([^/]*)')", u.ap_id), + where: is_nil(i.unreachable_since) + ) + end + defp maybe_resolve(true, user, query) do case {limit(), user} do {:all, _} -> :noop @@ -236,6 +246,16 @@ defmodule Pleroma.User.Search do from(u in subquery(query), select_merge: %{ + search_type: + fragment( + """ + CASE WHEN (?) THEN 2 + WHEN (?) THEN 1 + ELSE 0 END + """, + u.id in ^top_user_ids, + u.id in ^friends_ids or u.id in ^followers_ids + ), search_rank: fragment( """ @@ -261,6 +281,14 @@ defmodule Pleroma.User.Search do defp boost_search_rank(query, _for_user, top_user_ids) do from(u in subquery(query), select_merge: %{ + search_type: + fragment( + """ + CASE WHEN (?) THEN 2 + ELSE 0 END + """, + u.id in ^top_user_ids + ), search_rank: fragment( """ @@ -273,4 +301,22 @@ defmodule Pleroma.User.Search do } ) end + + defp order_by_search_rank(query, %User{}) do + order_by( + query, + [u], + desc: u.search_type, + desc_nulls_last: + fragment( + "CASE WHEN ? = 1 THEN COALESCE(?, ?) ELSE NULL END", + u.search_type, + u.last_status_at, + u.last_active_at + ), + desc: u.search_rank + ) + end + + defp order_by_search_rank(query, _), do: order_by(query, desc: :search_rank) end diff --git a/test/pleroma/user/search_test.exs b/test/pleroma/user/search_test.exs new file mode 100644 index 000000000..c1aca90bc --- /dev/null +++ b/test/pleroma/user/search_test.exs @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.SearchTest do + use Pleroma.DataCase, async: false + + import Pleroma.Factory + + alias Pleroma.Instances + alias Pleroma.Repo + alias Pleroma.User + + describe "search/2 mention suggestions" do + test "prioritizes followed/follower users before others" do + user = insert(:user) + + related = + insert(:user, + local: false, + nickname: "hj@real.example", + ap_id: "https://real.example/users/hj", + last_status_at: ~N[2020-01-01 00:00:00] + ) + + other = insert(:user, nickname: "hj", last_status_at: ~N[2020-01-02 00:00:00]) + + {:ok, _related, _user} = User.follow(related, user) + + results = User.search("hj", for_user: user) |> Enum.map(& &1.id) + + assert results == [related.id, other.id] + end + + test "orders followed/follower users by most recent activity" do + user = insert(:user) + + older = + insert(:user, + local: false, + nickname: "ali@remote.example", + ap_id: "https://remote.example/users/ali", + last_status_at: ~N[2020-01-01 00:00:00] + ) + + newer = + insert(:user, + local: false, + nickname: "alia@remote.example", + ap_id: "https://remote.example/users/alia", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + {:ok, _user, _older} = User.follow(user, older) + {:ok, _user, _newer} = User.follow(user, newer) + + assert [newer.id, older.id] == + User.search("ali", for_user: user) + |> Enum.map(& &1.id) + end + + test "groups followed/follower users first and sorts them by recency" do + user = insert(:user) + + following_newest = + insert(:user, + local: false, + nickname: "mentiontesta@related.example", + ap_id: "https://related.example/users/mentiontesta", + last_status_at: ~N[2020-01-03 00:00:00] + ) + + follower_middle = + insert(:user, + local: false, + nickname: "mentiontestb@related.example", + ap_id: "https://related.example/users/mentiontestb", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + mutual_oldest = + insert(:user, + local: false, + nickname: "mentiontestc@related.example", + ap_id: "https://related.example/users/mentiontestc", + last_status_at: ~N[2020-01-01 00:00:00] + ) + + unrelated_newer = + insert(:user, + local: false, + nickname: "mentiontestd@unrelated.example", + ap_id: "https://unrelated.example/users/mentiontestd", + last_status_at: ~N[2020-01-04 00:00:00] + ) + + {:ok, _user, _following_newest} = User.follow(user, following_newest) + {:ok, _follower_middle, _user} = User.follow(follower_middle, user) + + {:ok, _user, _mutual_oldest} = User.follow(user, mutual_oldest) + {:ok, _mutual_oldest, _user} = User.follow(mutual_oldest, user) + + results = User.search("mentiontest", for_user: user) + + assert Enum.map(results, & &1.id) == + [following_newest.id, follower_middle.id, mutual_oldest.id, unrelated_newer.id] + end + + test "uses last_active_at when last_status_at is missing" do + user = insert(:user) + + older = + insert(:user, + local: false, + nickname: "activefallbacka@remote.example", + ap_id: "https://remote.example/users/activefallbacka", + last_status_at: nil, + last_active_at: ~N[2020-01-01 00:00:00] + ) + + newer = + insert(:user, + local: false, + nickname: "activefallbackb@remote.example", + ap_id: "https://remote.example/users/activefallbackb", + last_status_at: nil, + last_active_at: ~N[2020-01-02 00:00:00] + ) + + {:ok, _user, _older} = User.follow(user, older) + {:ok, _user, _newer} = User.follow(user, newer) + + assert [newer.id, older.id] == + User.search("activefallback", for_user: user) + |> Enum.map(& &1.id) + end + + test "does not return deactivated users even if related" do + user = insert(:user) + + active = + insert(:user, + local: false, + nickname: "deactivatedtesta@remote.example", + ap_id: "https://remote.example/users/deactivatedtesta", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + deactivated = + insert(:user, + local: false, + nickname: "deactivatedtestb@remote.example", + ap_id: "https://remote.example/users/deactivatedtestb", + last_status_at: ~N[2020-01-03 00:00:00] + ) + + {:ok, _user, _active} = User.follow(user, active) + {:ok, _user, _deactivated} = User.follow(user, deactivated) + Repo.update!(Ecto.Changeset.change(deactivated, is_active: false)) + + results = User.search("deactivatedtest", for_user: user) |> Enum.map(& &1.id) + + assert results == [active.id] + end + + test "does not return users from unreachable instances" do + user = insert(:user) + + {:ok, _instance} = Instances.set_unreachable("dead.example") + + dead = + insert(:user, + local: false, + nickname: "ali@dead.example", + ap_id: "https://dead.example/users/ali", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + alive = + insert(:user, + local: false, + nickname: "ali@alive.example", + ap_id: "https://alive.example/users/ali", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + {:ok, _user, _alive} = User.follow(user, alive) + {:ok, _user, _dead} = User.follow(user, dead) + + results = User.search("ali", for_user: user) |> Enum.map(& &1.id) + + assert results == [alive.id] + end + end +end