def run(["timeline_query", nickname]) do
start_pleroma()
-
params = %{local: true}
with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do
- followed_hashtags =
- user
- |> User.followed_hashtags()
- |> Enum.map(& &1.id)
-
params =
params
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:announce_filtering_user, user)
|> Map.put(:user, user)
|> Map.put(:local_only, params[:local])
- |> Map.put(:hashtags, followed_hashtags)
|> Map.delete(:local)
_activities =
alias Ecto.Multi
alias Pleroma.Hashtag
- alias Pleroma.User.HashtagFollow
alias Pleroma.Object
alias Pleroma.Repo
|> 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})
{: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
alias Pleroma.Emoji
alias Pleroma.FollowingRelationship
alias Pleroma.Formatter
- alias Pleroma.Hashtag
- alias Pleroma.User.HashtagFollow
alias Pleroma.HTML
alias Pleroma.Keys
alias Pleroma.MFA
has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile)
- many_to_many(:followed_hashtags, Hashtag,
- on_replace: :delete,
- on_delete: :delete_all,
- join_through: HashtagFollow
- )
-
for {relationship_type,
[
{outgoing_relation, outgoing_relation_target},
_ -> {:error, user}
end
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
+++ /dev/null
-defmodule Pleroma.User.HashtagFollow do
- use Ecto.Schema
- import Ecto.Query
- import Ecto.Changeset
-
- alias Pleroma.User
- alias Pleroma.Hashtag
- alias Pleroma.Repo
-
- 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
- Ecto.assoc(user, :followed_hashtags)
- |> Repo.all()
- end
-end
)
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: hto in "hashtags_objects",
- on: hto.object_id == object.id,
- where:
- (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
|> maybe_preload_report_notes(opts)
|> maybe_set_thread_muted_field(opts)
|> maybe_order(opts)
- |> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
+ |> restrict_recipients(recipients, opts[:user])
|> restrict_replies(opts)
|> restrict_since(opts)
|> restrict_local(opts)
+++ /dev/null
-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:follow"]}],
- parameters: [id_param()],
- operationId: "TagController.unfollow",
- responses: %{
- 200 => Operation.response("Hashtag", "application/json", Tag),
- 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
-end
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"
}
},
example: %{
name: "cofe",
- url: "https://lain.com/tag/cofe",
- following: false
+ url: "https://lain.com/tag/cofe"
}
})
end
+++ /dev/null
-defmodule Pleroma.Web.MastodonAPI.TagController do
- @moduledoc "Hashtag routes for mastodon API"
- use Pleroma.Web, :controller
-
- alias Pleroma.User
- alias Pleroma.Hashtag
-
- plug(Pleroma.Web.ApiSpec.CastAndValidate)
- plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action in [:show])
-
- 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
-end
# 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"])
|> 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 =
+++ /dev/null
-defmodule Pleroma.Web.MastodonAPI.TagView do
- use Pleroma.Web, :view
- alias Pleroma.User
- alias Pleroma.Web.Router.Helpers
-
- 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
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)
end
scope "/api/web", Pleroma.Web do
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.StreamerView
- require Pleroma.Constants
@mix_env Mix.env()
@registry Pleroma.Web.StreamerRegistry
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
- 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 ->
+ Enum.each(recipient_topics, fn topic ->
push_to_socket(topic, item)
end)
end
+++ /dev/null
-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]))
- end
-end
assert user.ap_id in user3_updated.also_known_as
end
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
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)
-
- {: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([], %{followed_hashtags: [hashtag.id]})
- assert length(activities) == 2
- public_id = public.id
- unlisted_id = unlisted.id
- assert [%{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"})
+++ /dev/null
-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
-end
assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
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
user: user
}
end
-
- def hashtag_factory(params \\ %{}) do
- %Pleroma.Hashtag{
- name: "test #{sequence(:hashtag_name, & &1)}"
- }
- |> Map.merge(params)
- end
end