From c772f9b40a3b4a09e2c27ccf3eea1a839fa6e92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 21 Jul 2026 01:18:55 +0200 Subject: [PATCH 1/5] Add a failing test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../controllers/timeline_controller_test.exs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index b685f4ff8..b8d6129bb 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -907,21 +907,25 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do {:ok, activity_test1} = CommonAPI.post(user, %{status: "#test #test1"}) {:ok, activity_none} = CommonAPI.post(user, %{status: "#test #none"}) - any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1") + for hashtag_timeline_mode <- [:enabled, :disabled] do + clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_mode) - [status_none, status_test1, status_test] = json_response_and_validate_schema(any_test, :ok) + any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1") - assert to_string(activity_test.id) == status_test["id"] - assert to_string(activity_test1.id) == status_test1["id"] - assert to_string(activity_none.id) == status_none["id"] + [status_none, status_test1, status_test] = json_response_and_validate_schema(any_test, :ok) - restricted_test = get(conn, "/api/v1/timelines/tag/test?all[]=test1&none[]=none") + assert to_string(activity_test.id) == status_test["id"] + assert to_string(activity_test1.id) == status_test1["id"] + assert to_string(activity_none.id) == status_none["id"] - assert [status_test1] == json_response_and_validate_schema(restricted_test, :ok) + restricted_test = get(conn, "/api/v1/timelines/tag/test?all[]=test1&none[]=none") - all_test = get(conn, "/api/v1/timelines/tag/test?all[]=none") + assert [status_test1] == json_response_and_validate_schema(restricted_test, :ok) - assert [status_none] == json_response_and_validate_schema(all_test, :ok) + all_test = get(conn, "/api/v1/timelines/tag/test?all[]=none") + + assert [status_none] == json_response_and_validate_schema(all_test, :ok) + end end test "muted emotions", %{conn: conn} do From 59269cf07a42e0f6ac406c99f47414fa98f3e71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 21 Jul 2026 01:22:33 +0200 Subject: [PATCH 2/5] Fix hashtag timeline query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- lib/pleroma/web/activity_pub/activity_pub.ex | 24 ++++++++++++------- .../controllers/timeline_controller_test.exs | 3 ++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1e8bfe3dd..a564a634d 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -893,14 +893,22 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Repo.all() # Note: NO extra ordering should be done on "activities.id desc nulls last" for optimal plan - from( - [_activity, object] in query, - join: hto in "hashtags_objects", - on: hto.object_id == object.id, - where: hto.hashtag_id in ^hashtag_ids, - distinct: [desc: object.id], - order_by: [desc: object.id] - ) + query = + from( + [_activity, object] in query, + join: hto in "hashtags_objects", + on: hto.object_id == object.id, + where: hto.hashtag_id in ^hashtag_ids + ) + + if query.distinct == [] do + from([activity, object] in query, + distinct: [desc: activity.id], + order_by: [desc: activity.id] + ) + else + query + end end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index b8d6129bb..19f75fe0e 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -912,7 +912,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1") - [status_none, status_test1, status_test] = json_response_and_validate_schema(any_test, :ok) + [status_none, status_test1, status_test] = + json_response_and_validate_schema(any_test, :ok) assert to_string(activity_test.id) == status_test["id"] assert to_string(activity_test1.id) == status_test1["id"] From 2a56a02d9a3049533495ba5690ff12e703cca200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 21 Jul 2026 01:25:05 +0200 Subject: [PATCH 3/5] changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/hashtag-timeline.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/hashtag-timeline.fix diff --git a/changelog.d/hashtag-timeline.fix b/changelog.d/hashtag-timeline.fix new file mode 100644 index 000000000..c5814d9c9 --- /dev/null +++ b/changelog.d/hashtag-timeline.fix @@ -0,0 +1 @@ +Fix hashtag timeline query for additional tag handling From b97092a97e2669f75d117103ab69e30255092b59 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 18:36:25 +0400 Subject: [PATCH 4/5] fix: preserve optimized hashtag ordering --- lib/pleroma/web/activity_pub/activity_pub.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index a564a634d..4efd18d39 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -901,10 +901,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do where: hto.hashtag_id in ^hashtag_ids ) - if query.distinct == [] do - from([activity, object] in query, - distinct: [desc: activity.id], - order_by: [desc: activity.id] + if is_nil(query.distinct) do + from([_activity, object] in query, + distinct: [desc: object.id], + order_by: [desc: object.id] ) else query From 77b5f3f485049fb4fe3b81ac71f5ef28c0e1e264 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 18:49:44 +0400 Subject: [PATCH 5/5] test: cover multi-hashtag pagination --- .../controllers/timeline_controller_test.exs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index 19f75fe0e..bed897e23 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -929,6 +929,33 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do end end + test "multi-hashtag timeline pagination returns unique statuses", %{conn: conn} do + user = insert(:user) + + {:ok, activity_one} = CommonAPI.post(user, %{status: "#test #test1"}) + {:ok, activity_two} = CommonAPI.post(user, %{status: "#test #test1"}) + {:ok, activity_three} = CommonAPI.post(user, %{status: "#test #test1"}) + + for hashtag_timeline_mode <- [:enabled, :disabled] do + clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_mode) + + first_page = + conn + |> get("/api/v1/timelines/tag/test?any[]=test1&limit=2") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(first_page, & &1["id"]) == + Enum.map([activity_three, activity_two], &to_string(&1.id)) + + second_page = + conn + |> get("/api/v1/timelines/tag/test?any[]=test1&limit=2&max_id=#{activity_two.id}") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(second_page, & &1["id"]) == [to_string(activity_one.id)] + end + end + test "muted emotions", %{conn: conn} do user = insert(:user) token = insert(:oauth_token, user: user, scopes: ["read:statuses"])