commit: 801a2256f41778754f9b1e40f2681ebda5d4b5d1
parent fe3c72f7a278fe3db822faaf5428a004833436aa
Author: lain <lain@soykaf.club>
Date: Sat, 22 Feb 2025 12:26:18 +0000
Merge branch 'follow-hashtags' into 'develop'
Hashtag following (from Akkoma)
See merge request pleroma/pleroma!4307
Diffstat:
21 files changed, 720 insertions(+), 8 deletions(-)
diff --git a/changelog.d/follow-hashtags.add b/changelog.d/follow-hashtags.add
@@ -0,0 +1 @@
+Hashtag following
diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex
@@ -12,6 +12,7 @@ defmodule Pleroma.Hashtag do
alias Pleroma.Hashtag
alias Pleroma.Object
alias Pleroma.Repo
+ alias Pleroma.User.HashtagFollow
schema "hashtags" do
field(:name, :string)
@@ -27,6 +28,14 @@ defmodule Pleroma.Hashtag do
|> String.trim()
end
+ def get_by_id(id) do
+ Repo.get(Hashtag, id)
+ end
+
+ def get_by_name(name) do
+ Repo.get_by(Hashtag, name: normalize_name(name))
+ end
+
def get_or_create_by_name(name) do
changeset = changeset(%Hashtag{}, %{name: name})
@@ -103,4 +112,22 @@ defmodule Pleroma.Hashtag do
{:ok, deleted_count}
end
end
+
+ def get_followers(%Hashtag{id: hashtag_id}) do
+ from(hf in HashtagFollow)
+ |> where([hf], hf.hashtag_id == ^hashtag_id)
+ |> join(:inner, [hf], u in assoc(hf, :user))
+ |> select([hf, u], u.id)
+ |> Repo.all()
+ end
+
+ def get_recipients_for_activity(%Pleroma.Activity{object: %{hashtags: tags}})
+ when is_list(tags) do
+ tags
+ |> Enum.map(&get_followers/1)
+ |> List.flatten()
+ |> Enum.uniq()
+ end
+
+ def get_recipients_for_activity(_activity), do: []
end
diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex
@@ -89,9 +89,9 @@ defmodule Pleroma.Pagination do
defp cast_params(params) do
param_types = %{
- min_id: :string,
- since_id: :string,
- max_id: :string,
+ min_id: params[:id_type] || :string,
+ since_id: params[:id_type] || :string,
+ max_id: params[:id_type] || :string,
offset: :integer,
limit: :integer,
skip_extra_order: :boolean,
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
@@ -19,6 +19,7 @@ defmodule Pleroma.User do
alias Pleroma.Emoji
alias Pleroma.FollowingRelationship
alias Pleroma.Formatter
+ alias Pleroma.Hashtag
alias Pleroma.HTML
alias Pleroma.Keys
alias Pleroma.MFA
@@ -27,6 +28,7 @@ defmodule Pleroma.User do
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
+ alias Pleroma.User.HashtagFollow
alias Pleroma.UserRelationship
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
@@ -174,6 +176,12 @@ defmodule Pleroma.User do
has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id)
has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id)
+ many_to_many(:followed_hashtags, Hashtag,
+ on_replace: :delete,
+ on_delete: :delete_all,
+ join_through: HashtagFollow
+ )
+
for {relationship_type,
[
{outgoing_relation, outgoing_relation_target},
@@ -2861,4 +2869,54 @@ defmodule Pleroma.User do
birthday_month: month
})
end
+
+ defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
+ when is_list(follows),
+ do: user
+
+ defp maybe_load_followed_hashtags(%User{} = user) do
+ followed_hashtags = HashtagFollow.get_by_user(user)
+ %{user | followed_hashtags: followed_hashtags}
+ end
+
+ def followed_hashtags(%User{followed_hashtags: follows})
+ when is_list(follows),
+ do: follows
+
+ def followed_hashtags(%User{} = user) do
+ {:ok, user} =
+ user
+ |> maybe_load_followed_hashtags()
+ |> set_cache()
+
+ user.followed_hashtags
+ end
+
+ def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+ Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
+ user = maybe_load_followed_hashtags(user)
+
+ with {:ok, _} <- HashtagFollow.new(user, hashtag),
+ follows <- HashtagFollow.get_by_user(user),
+ %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+ user
+ |> set_cache()
+ end
+ end
+
+ def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+ Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
+ user = maybe_load_followed_hashtags(user)
+
+ with {:ok, _} <- HashtagFollow.delete(user, hashtag),
+ follows <- HashtagFollow.get_by_user(user),
+ %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+ user
+ |> set_cache()
+ end
+ end
+
+ def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
+ not is_nil(HashtagFollow.get(user, hashtag))
+ end
end
diff --git a/lib/pleroma/user/hashtag_follow.ex b/lib/pleroma/user/hashtag_follow.ex
@@ -0,0 +1,55 @@
+defmodule Pleroma.User.HashtagFollow do
+ use Ecto.Schema
+ import Ecto.Query
+ import Ecto.Changeset
+
+ alias Pleroma.Hashtag
+ alias Pleroma.Repo
+ alias Pleroma.User
+
+ schema "user_follows_hashtag" do
+ belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+ belongs_to(:hashtag, Hashtag)
+ end
+
+ def changeset(%__MODULE__{} = user_hashtag_follow, attrs) do
+ user_hashtag_follow
+ |> cast(attrs, [:user_id, :hashtag_id])
+ |> unique_constraint(:hashtag_id,
+ name: :user_hashtag_follows_user_id_hashtag_id_index,
+ message: "already following"
+ )
+ |> validate_required([:user_id, :hashtag_id])
+ end
+
+ def new(%User{} = user, %Hashtag{} = hashtag) do
+ %__MODULE__{}
+ |> changeset(%{user_id: user.id, hashtag_id: hashtag.id})
+ |> Repo.insert(on_conflict: :nothing)
+ end
+
+ def delete(%User{} = user, %Hashtag{} = hashtag) do
+ with %__MODULE__{} = user_hashtag_follow <- get(user, hashtag) do
+ Repo.delete(user_hashtag_follow)
+ else
+ _ -> {:ok, nil}
+ end
+ end
+
+ def get(%User{} = user, %Hashtag{} = hashtag) do
+ from(hf in __MODULE__)
+ |> where([hf], hf.user_id == ^user.id and hf.hashtag_id == ^hashtag.id)
+ |> Repo.one()
+ end
+
+ def get_by_user(%User{} = user) do
+ user
+ |> followed_hashtags_query()
+ |> Repo.all()
+ end
+
+ def followed_hashtags_query(%User{} = user) do
+ Ecto.assoc(user, :followed_hashtags)
+ |> Ecto.Query.order_by([h], desc: h.id)
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -924,6 +924,31 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
)
end
+ # Essentially, either look for activities addressed to `recipients`, _OR_ ones
+ # that reference a hashtag that the user follows
+ # Firstly, two fallbacks in case there's no hashtag constraint, or the user doesn't
+ # follow any
+ defp restrict_recipients_or_hashtags(query, recipients, user, nil) do
+ restrict_recipients(query, recipients, user)
+ end
+
+ defp restrict_recipients_or_hashtags(query, recipients, user, []) do
+ restrict_recipients(query, recipients, user)
+ end
+
+ defp restrict_recipients_or_hashtags(query, recipients, _user, hashtag_ids) do
+ from([activity, object] in query)
+ |> join(:left, [activity, object], hto in "hashtags_objects",
+ on: hto.object_id == object.id,
+ as: :hto
+ )
+ |> where(
+ [activity, object, hto: hto],
+ (hto.hashtag_id in ^hashtag_ids and ^Constants.as_public() in activity.recipients) or
+ fragment("? && ?", ^recipients, activity.recipients)
+ )
+ end
+
defp restrict_local(query, %{local_only: true}) do
from(activity in query, where: activity.local == true)
end
@@ -1414,7 +1439,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> maybe_preload_report_notes(opts)
|> maybe_set_thread_muted_field(opts)
|> maybe_order(opts)
- |> restrict_recipients(recipients, opts[:user])
+ |> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
|> restrict_replies(opts)
|> restrict_since(opts)
|> restrict_local(opts)
diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex
@@ -139,7 +139,8 @@ defmodule Pleroma.Web.ApiSpec do
"Search",
"Status actions",
"Media attachments",
- "Bookmark folders"
+ "Bookmark folders",
+ "Tags"
]
},
%{
diff --git a/lib/pleroma/web/api_spec/operations/tag_operation.ex b/lib/pleroma/web/api_spec/operations/tag_operation.ex
@@ -0,0 +1,103 @@
+defmodule Pleroma.Web.ApiSpec.TagOperation do
+ alias OpenApiSpex.Operation
+ alias OpenApiSpex.Schema
+ alias Pleroma.Web.ApiSpec.Schemas.ApiError
+ alias Pleroma.Web.ApiSpec.Schemas.Tag
+
+ def open_api_operation(action) do
+ operation = String.to_existing_atom("#{action}_operation")
+ apply(__MODULE__, operation, [])
+ end
+
+ def show_operation do
+ %Operation{
+ tags: ["Tags"],
+ summary: "Hashtag",
+ description: "View a hashtag",
+ security: [%{"oAuth" => ["read"]}],
+ parameters: [id_param()],
+ operationId: "TagController.show",
+ responses: %{
+ 200 => Operation.response("Hashtag", "application/json", Tag),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ def follow_operation do
+ %Operation{
+ tags: ["Tags"],
+ summary: "Follow a hashtag",
+ description: "Follow a hashtag",
+ security: [%{"oAuth" => ["write:follows"]}],
+ parameters: [id_param()],
+ operationId: "TagController.follow",
+ responses: %{
+ 200 => Operation.response("Hashtag", "application/json", Tag),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ def unfollow_operation do
+ %Operation{
+ tags: ["Tags"],
+ summary: "Unfollow a hashtag",
+ description: "Unfollow a hashtag",
+ security: [%{"oAuth" => ["write:follows"]}],
+ parameters: [id_param()],
+ operationId: "TagController.unfollow",
+ responses: %{
+ 200 => Operation.response("Hashtag", "application/json", Tag),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ def show_followed_operation do
+ %Operation{
+ tags: ["Tags"],
+ summary: "Followed hashtags",
+ description: "View a list of hashtags the currently authenticated user is following",
+ parameters: pagination_params(),
+ security: [%{"oAuth" => ["read:follows"]}],
+ operationId: "TagController.show_followed",
+ responses: %{
+ 200 =>
+ Operation.response("Hashtags", "application/json", %Schema{
+ type: :array,
+ items: Tag
+ }),
+ 403 => Operation.response("Forbidden", "application/json", ApiError),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ defp id_param do
+ Operation.parameter(
+ :id,
+ :path,
+ %Schema{type: :string},
+ "Name of the hashtag"
+ )
+ end
+
+ def pagination_params do
+ [
+ Operation.parameter(:max_id, :query, :integer, "Return items older than this ID"),
+ Operation.parameter(
+ :min_id,
+ :query,
+ :integer,
+ "Return the oldest items newer than this ID"
+ ),
+ Operation.parameter(
+ :limit,
+ :query,
+ %Schema{type: :integer, default: 20},
+ "Maximum number of items to return. Will be ignored if it's more than 40"
+ )
+ ]
+ end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/tag.ex b/lib/pleroma/web/api_spec/schemas/tag.ex
@@ -17,11 +17,22 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Tag do
type: :string,
format: :uri,
description: "A link to the hashtag on the instance"
+ },
+ following: %Schema{
+ type: :boolean,
+ description: "Whether the authenticated user is following the hashtag"
+ },
+ history: %Schema{
+ type: :array,
+ items: %Schema{type: :string},
+ description:
+ "A list of historical uses of the hashtag (not implemented, for compatibility only)"
}
},
example: %{
name: "cofe",
- url: "https://lain.com/tag/cofe"
+ url: "https://lain.com/tag/cofe",
+ following: false
}
})
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex b/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex
@@ -0,0 +1,77 @@
+defmodule Pleroma.Web.MastodonAPI.TagController do
+ @moduledoc "Hashtag routes for mastodon API"
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Hashtag
+ alias Pleroma.Pagination
+ alias Pleroma.User
+
+ import Pleroma.Web.ControllerHelper,
+ only: [
+ add_link_headers: 2
+ ]
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(
+ Pleroma.Web.Plugs.OAuthScopesPlug,
+ %{scopes: ["read"]} when action in [:show]
+ )
+
+ plug(
+ Pleroma.Web.Plugs.OAuthScopesPlug,
+ %{scopes: ["read:follows"]} when action in [:show_followed]
+ )
+
+ plug(
+ Pleroma.Web.Plugs.OAuthScopesPlug,
+ %{scopes: ["write:follows"]} when action in [:follow, :unfollow]
+ )
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TagOperation
+
+ def show(conn, %{id: id}) do
+ with %Hashtag{} = hashtag <- Hashtag.get_by_name(id) do
+ render(conn, "show.json", tag: hashtag, for_user: conn.assigns.user)
+ else
+ _ -> conn |> render_error(:not_found, "Hashtag not found")
+ end
+ end
+
+ def follow(conn, %{id: id}) do
+ with %Hashtag{} = hashtag <- Hashtag.get_by_name(id),
+ %User{} = user <- conn.assigns.user,
+ {:ok, _} <-
+ User.follow_hashtag(user, hashtag) do
+ render(conn, "show.json", tag: hashtag, for_user: user)
+ else
+ _ -> render_error(conn, :not_found, "Hashtag not found")
+ end
+ end
+
+ def unfollow(conn, %{id: id}) do
+ with %Hashtag{} = hashtag <- Hashtag.get_by_name(id),
+ %User{} = user <- conn.assigns.user,
+ {:ok, _} <-
+ User.unfollow_hashtag(user, hashtag) do
+ render(conn, "show.json", tag: hashtag, for_user: user)
+ else
+ _ -> render_error(conn, :not_found, "Hashtag not found")
+ end
+ end
+
+ def show_followed(conn, params) do
+ with %{assigns: %{user: %User{} = user}} <- conn do
+ params = Map.put(params, :id_type, :integer)
+
+ hashtags =
+ user
+ |> User.HashtagFollow.followed_hashtags_query()
+ |> Pagination.fetch_paginated(params)
+
+ conn
+ |> add_link_headers(hashtags)
+ |> render("index.json", tags: hashtags, for_user: user)
+ end
+ end
+end
diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
@@ -40,6 +40,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
# GET /api/v1/timelines/home
def home(%{assigns: %{user: user}} = conn, params) do
+ followed_hashtags =
+ user
+ |> User.followed_hashtags()
+ |> Enum.map(& &1.id)
+
params =
params
|> Map.put(:type, ["Create", "Announce"])
@@ -49,6 +54,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
|> Map.put(:announce_filtering_user, user)
|> Map.put(:user, user)
|> Map.put(:local_only, params[:local])
+ |> Map.put(:followed_hashtags, followed_hashtags)
|> Map.delete(:local)
activities =
diff --git a/lib/pleroma/web/mastodon_api/views/tag_view.ex b/lib/pleroma/web/mastodon_api/views/tag_view.ex
@@ -0,0 +1,25 @@
+defmodule Pleroma.Web.MastodonAPI.TagView do
+ use Pleroma.Web, :view
+ alias Pleroma.User
+ alias Pleroma.Web.Router.Helpers
+
+ def render("index.json", %{tags: tags, for_user: user}) do
+ safe_render_many(tags, __MODULE__, "show.json", %{for_user: user})
+ end
+
+ def render("show.json", %{tag: tag, for_user: user}) do
+ following =
+ with %User{} <- user do
+ User.following_hashtag?(user, tag)
+ else
+ _ -> false
+ end
+
+ %{
+ name: tag.name,
+ url: Helpers.tag_feed_url(Pleroma.Web.Endpoint, :feed, tag.name),
+ history: [],
+ following: following
+ }
+ end
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
@@ -755,6 +755,11 @@ defmodule Pleroma.Web.Router do
get("/announcements", AnnouncementController, :index)
post("/announcements/:id/dismiss", AnnouncementController, :mark_read)
+
+ get("/tags/:id", TagController, :show)
+ post("/tags/:id/follow", TagController, :follow)
+ post("/tags/:id/unfollow", TagController, :unfollow)
+ get("/followed_tags", TagController, :show_followed)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex
@@ -19,6 +19,7 @@ defmodule Pleroma.Web.Streamer do
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.StreamerView
+ require Pleroma.Constants
@registry Pleroma.Web.StreamerRegistry
@@ -305,7 +306,17 @@ defmodule Pleroma.Web.Streamer do
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
- Enum.each(recipient_topics, fn topic ->
+ hashtag_recipients =
+ if Pleroma.Constants.as_public() in item.recipients do
+ Pleroma.Hashtag.get_recipients_for_activity(item)
+ |> Enum.map(fn id -> "user:#{id}" end)
+ else
+ []
+ end
+
+ all_recipients = Enum.uniq(recipient_topics ++ hashtag_recipients)
+
+ Enum.each(all_recipients, fn topic ->
push_to_socket(topic, item)
end)
end
diff --git a/priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs b/priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs
@@ -0,0 +1,14 @@
+defmodule Pleroma.Repo.Migrations.AddUserFollowsHashtag do
+ use Ecto.Migration
+
+ def change do
+ create table(:user_follows_hashtag) do
+ add(:hashtag_id, references(:hashtags))
+ add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+ end
+
+ create(unique_index(:user_follows_hashtag, [:user_id, :hashtag_id]))
+
+ create_if_not_exists(index(:user_follows_hashtag, [:hashtag_id]))
+ end
+end
diff --git a/test/mix/tasks/pleroma/database_test.exs b/test/mix/tasks/pleroma/database_test.exs
@@ -411,7 +411,7 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
["scheduled_activities"],
["schema_migrations"],
["thread_mutes"],
- # ["user_follows_hashtag"], # not in pleroma
+ ["user_follows_hashtag"],
# ["user_frontend_setting_profiles"], # not in pleroma
["user_invite_tokens"],
["user_notes"],
diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs
@@ -2919,4 +2919,74 @@ defmodule Pleroma.UserTest do
assert [%{"verified_at" => ^verified_at}] = user.fields
end
+
+ describe "follow_hashtag/2" do
+ test "should follow a hashtag" do
+ user = insert(:user)
+ hashtag = insert(:hashtag)
+
+ assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+ user = User.get_cached_by_ap_id(user.ap_id)
+
+ assert user.followed_hashtags |> Enum.count() == 1
+ assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+ end
+
+ test "should not follow a hashtag twice" do
+ user = insert(:user)
+ hashtag = insert(:hashtag)
+
+ assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+ assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+ user = User.get_cached_by_ap_id(user.ap_id)
+
+ assert user.followed_hashtags |> Enum.count() == 1
+ assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+ end
+
+ test "can follow multiple hashtags" do
+ user = insert(:user)
+ hashtag = insert(:hashtag)
+ other_hashtag = insert(:hashtag)
+
+ assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+ assert {:ok, _} = user |> User.follow_hashtag(other_hashtag)
+
+ user = User.get_cached_by_ap_id(user.ap_id)
+
+ assert user.followed_hashtags |> Enum.count() == 2
+ assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+ assert other_hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+ end
+ end
+
+ describe "unfollow_hashtag/2" do
+ test "should unfollow a hashtag" do
+ user = insert(:user)
+ hashtag = insert(:hashtag)
+
+ assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+ assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+
+ user = User.get_cached_by_ap_id(user.ap_id)
+
+ assert user.followed_hashtags |> Enum.count() == 0
+ end
+
+ test "should not error when trying to unfollow a hashtag twice" do
+ user = insert(:user)
+ hashtag = insert(:hashtag)
+
+ assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+ assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+ assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+
+ user = User.get_cached_by_ap_id(user.ap_id)
+
+ assert user.followed_hashtags |> Enum.count() == 0
+ end
+ end
end
diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs
@@ -867,6 +867,33 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
end
+ describe "fetch activities for followed hashtags" do
+ test "it should return public activities that reference a given hashtag" do
+ hashtag = insert(:hashtag, name: "tenshi")
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, normally_visible} =
+ CommonAPI.post(other_user, %{status: "hello :)", visibility: "public"})
+
+ {:ok, public} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "public"})
+ {:ok, _unrelated} = CommonAPI.post(user, %{status: "dai #tensh", visibility: "public"})
+ {:ok, unlisted} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "unlisted"})
+ {:ok, _private} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "private"})
+
+ activities =
+ ActivityPub.fetch_activities([other_user.follower_address], %{
+ followed_hashtags: [hashtag.id]
+ })
+
+ assert length(activities) == 3
+ normal_id = normally_visible.id
+ public_id = public.id
+ unlisted_id = unlisted.id
+ assert [%{id: ^normal_id}, %{id: ^public_id}, %{id: ^unlisted_id}] = activities
+ end
+ end
+
describe "fetch activities in context" do
test "retrieves activities that have a given context" do
{:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
diff --git a/test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs
@@ -0,0 +1,159 @@
+defmodule Pleroma.Web.MastodonAPI.TagControllerTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+ import Tesla.Mock
+
+ alias Pleroma.User
+
+ setup do
+ mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+
+ describe "GET /api/v1/tags/:id" do
+ test "returns 200 with tag" do
+ %{user: user, conn: conn} = oauth_access(["read"])
+
+ tag = insert(:hashtag, name: "jubjub")
+ {:ok, _user} = User.follow_hashtag(user, tag)
+
+ response =
+ conn
+ |> get("/api/v1/tags/jubjub")
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "name" => "jubjub",
+ "url" => "http://localhost:4001/tags/jubjub",
+ "history" => [],
+ "following" => true
+ } = response
+ end
+
+ test "returns 404 with unknown tag" do
+ %{conn: conn} = oauth_access(["read"])
+
+ conn
+ |> get("/api/v1/tags/jubjub")
+ |> json_response_and_validate_schema(404)
+ end
+ end
+
+ describe "POST /api/v1/tags/:id/follow" do
+ test "should follow a hashtag" do
+ %{user: user, conn: conn} = oauth_access(["write:follows"])
+ hashtag = insert(:hashtag, name: "jubjub")
+
+ response =
+ conn
+ |> post("/api/v1/tags/jubjub/follow")
+ |> json_response_and_validate_schema(200)
+
+ assert response["following"] == true
+ user = User.get_cached_by_ap_id(user.ap_id)
+ assert User.following_hashtag?(user, hashtag)
+ end
+
+ test "should 404 if hashtag doesn't exist" do
+ %{conn: conn} = oauth_access(["write:follows"])
+
+ response =
+ conn
+ |> post("/api/v1/tags/rubrub/follow")
+ |> json_response_and_validate_schema(404)
+
+ assert response["error"] == "Hashtag not found"
+ end
+ end
+
+ describe "POST /api/v1/tags/:id/unfollow" do
+ test "should unfollow a hashtag" do
+ %{user: user, conn: conn} = oauth_access(["write:follows"])
+ hashtag = insert(:hashtag, name: "jubjub")
+ {:ok, user} = User.follow_hashtag(user, hashtag)
+
+ response =
+ conn
+ |> post("/api/v1/tags/jubjub/unfollow")
+ |> json_response_and_validate_schema(200)
+
+ assert response["following"] == false
+ user = User.get_cached_by_ap_id(user.ap_id)
+ refute User.following_hashtag?(user, hashtag)
+ end
+
+ test "should 404 if hashtag doesn't exist" do
+ %{conn: conn} = oauth_access(["write:follows"])
+
+ response =
+ conn
+ |> post("/api/v1/tags/rubrub/unfollow")
+ |> json_response_and_validate_schema(404)
+
+ assert response["error"] == "Hashtag not found"
+ end
+ end
+
+ describe "GET /api/v1/followed_tags" do
+ test "should list followed tags" do
+ %{user: user, conn: conn} = oauth_access(["read:follows"])
+
+ response =
+ conn
+ |> get("/api/v1/followed_tags")
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.empty?(response)
+
+ hashtag = insert(:hashtag, name: "jubjub")
+ {:ok, _user} = User.follow_hashtag(user, hashtag)
+
+ response =
+ conn
+ |> get("/api/v1/followed_tags")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"name" => "jubjub"}] = response
+ end
+
+ test "should include a link header to paginate" do
+ %{user: user, conn: conn} = oauth_access(["read:follows"])
+
+ for i <- 1..21 do
+ hashtag = insert(:hashtag, name: "jubjub#{i}}")
+ {:ok, _user} = User.follow_hashtag(user, hashtag)
+ end
+
+ response =
+ conn
+ |> get("/api/v1/followed_tags")
+
+ json = json_response_and_validate_schema(response, 200)
+ assert Enum.count(json) == 20
+ assert [link_header] = get_resp_header(response, "link")
+ assert link_header =~ "rel=\"next\""
+ next_link = extract_next_link_header(link_header)
+
+ response =
+ conn
+ |> get(next_link)
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.count(response) == 1
+ end
+
+ test "should refuse access without read:follows scope" do
+ %{conn: conn} = oauth_access(["write"])
+
+ conn
+ |> get("/api/v1/followed_tags")
+ |> json_response_and_validate_schema(403)
+ end
+ end
+
+ defp extract_next_link_header(header) do
+ [_, next_link] = Regex.run(~r{<(?<next_link>.*)>; rel="next"}, header)
+ next_link
+ end
+end
diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs
@@ -558,6 +558,36 @@ defmodule Pleroma.Web.StreamerTest do
assert_receive {:render_with_user, _, "status_update.json", ^create, _}
refute Streamer.filtered_by_user?(user, edited)
end
+
+ test "it streams posts containing followed hashtags on the 'user' stream", %{
+ user: user,
+ token: oauth_token
+ } do
+ hashtag = insert(:hashtag, %{name: "tenshi"})
+ other_user = insert(:user)
+ {:ok, user} = User.follow_hashtag(user, hashtag)
+
+ Streamer.get_topic_and_add_socket("user", user, oauth_token)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hey #tenshi"})
+
+ assert_receive {:render_with_user, _, "update.json", ^activity, _}
+ end
+
+ test "should not stream private posts containing followed hashtags on the 'user' stream", %{
+ user: user,
+ token: oauth_token
+ } do
+ hashtag = insert(:hashtag, %{name: "tenshi"})
+ other_user = insert(:user)
+ {:ok, user} = User.follow_hashtag(user, hashtag)
+
+ Streamer.get_topic_and_add_socket("user", user, oauth_token)
+
+ {:ok, activity} =
+ CommonAPI.post(other_user, %{status: "hey #tenshi", visibility: "private"})
+
+ refute_receive {:render_with_user, _, "update.json", ^activity, _}
+ end
end
describe "public streams" do
diff --git a/test/support/factory.ex b/test/support/factory.ex
@@ -668,4 +668,11 @@ defmodule Pleroma.Factory do
|> Map.merge(params)
|> Pleroma.Announcement.add_rendered_properties()
end
+
+ def hashtag_factory(params \\ %{}) do
+ %Pleroma.Hashtag{
+ name: "test #{sequence(:hashtag_name, & &1)}"
+ }
+ |> Map.merge(params)
+ end
end