- Authentication: Added rate limit for password-authorized actions / login existence checks
- Metadata Link: Atom syndication Feed
- Mix task to re-count statuses for all users (`mix pleroma.count_statuses`)
+- Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints
### Changed
- **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
## Timelines
Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users.
+Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`.
## Statuses
- `is_seen`: true if the notification was read by the user
+## GET `/api/v1/notifications`
+
+Accepts additional parameters:
+
+- `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`.
+
## POST `/api/v1/statuses`
Additional parameters can be added to the JSON body/Form data:
import Ecto.Query
import Ecto.Changeset
+ require Logger
@type t :: %__MODULE__{}
end
def for_user_query(user, opts \\ []) do
- query =
- Notification
- |> where(user_id: ^user.id)
- |> where(
- [n, a],
+ Notification
+ |> where(user_id: ^user.id)
+ |> where(
+ [n, a],
+ fragment(
+ "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')",
+ a.actor
+ )
+ )
+ |> join(:inner, [n], activity in assoc(n, :activity))
+ |> join(:left, [n, a], object in Object,
+ on:
fragment(
- "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')",
- a.actor
+ "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)",
+ object.data,
+ a.data
)
- )
- |> join(:inner, [n], activity in assoc(n, :activity))
- |> join(:left, [n, a], object in Object,
- on:
- fragment(
- "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)",
- object.data,
- a.data
- )
- )
- |> preload([n, a, o], activity: {a, object: o})
+ )
+ |> preload([n, a, o], activity: {a, object: o})
+ |> exclude_muted(user, opts)
+ |> exclude_visibility(opts)
+ end
+
+ defp exclude_muted(query, _, %{with_muted: true}) do
+ query
+ end
+
+ defp exclude_muted(query, user, _opts) do
+ query
+ |> where([n, a], a.actor not in ^user.info.muted_notifications)
+ |> where([n, a], a.actor not in ^user.info.blocks)
+ |> where(
+ [n, a],
+ fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks
+ )
+ |> join(:left, [n, a], tm in Pleroma.ThreadMute,
+ on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data)
+ )
+ |> where([n, a, o, tm], is_nil(tm.user_id))
+ end
- if opts[:with_muted] do
+ @valid_visibilities ~w[direct unlisted public private]
+
+ defp exclude_visibility(query, %{exclude_visibilities: visibility})
+ when is_list(visibility) do
+ if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
query
- else
- where(query, [n, a], a.actor not in ^user.info.muted_notifications)
- |> where([n, a], a.actor not in ^user.info.blocks)
|> where(
[n, a],
- fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks
- )
- |> join(:left, [n, a], tm in Pleroma.ThreadMute,
- on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data)
+ not fragment(
+ "activity_visibility(?, ?, ?) = ANY (?)",
+ a.actor,
+ a.recipients,
+ a.data,
+ ^visibility
+ )
)
- |> where([n, a, o, tm], is_nil(tm.user_id))
+ else
+ Logger.error("Could not exclude visibility to #{visibility}")
+ query
end
end
+ defp exclude_visibility(query, %{exclude_visibilities: visibility})
+ when visibility in @valid_visibilities do
+ query
+ |> where(
+ [n, a],
+ not fragment(
+ "activity_visibility(?, ?, ?) = (?)",
+ a.actor,
+ a.recipients,
+ a.data,
+ ^visibility
+ )
+ )
+ end
+
+ defp exclude_visibility(query, %{exclude_visibilities: visibility})
+ when visibility not in @valid_visibilities do
+ Logger.error("Could not exclude visibility to #{visibility}")
+ query
+ end
+
+ defp exclude_visibility(query, _visibility), do: query
+
def for_user(user, opts \\ %{}) do
user
|> for_user_query(opts)
defp restrict_visibility(query, _visibility), do: query
+ defp exclude_visibility(query, %{"exclude_visibilities" => visibility})
+ when is_list(visibility) do
+ if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
+ from(
+ a in query,
+ where:
+ not fragment(
+ "activity_visibility(?, ?, ?) = ANY (?)",
+ a.actor,
+ a.recipients,
+ a.data,
+ ^visibility
+ )
+ )
+ else
+ Logger.error("Could not exclude visibility to #{visibility}")
+ query
+ end
+ end
+
+ defp exclude_visibility(query, %{"exclude_visibilities" => visibility})
+ when visibility in @valid_visibilities do
+ from(
+ a in query,
+ where:
+ not fragment(
+ "activity_visibility(?, ?, ?) = ?",
+ a.actor,
+ a.recipients,
+ a.data,
+ ^visibility
+ )
+ )
+ end
+
+ defp exclude_visibility(query, %{"exclude_visibilities" => visibility})
+ when visibility not in @valid_visibilities do
+ Logger.error("Could not exclude visibility to #{visibility}")
+ query
+ end
+
+ defp exclude_visibility(query, _visibility), do: query
+
defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _),
do: query
|> restrict_muted_reblogs(opts)
|> Activity.restrict_deactivated_users()
|> exclude_poll_votes(opts)
+ |> exclude_visibility(opts)
end
def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
defp cast_params(params) do
param_types = %{
exclude_types: {:array, :string},
+ exclude_visibilities: {:array, :string},
reblogs: :boolean,
with_muted: :boolean
}
end
end
+ describe "fetching excluded by visibility" do
+ test "it excludes by the appropriate visibility" do
+ user = insert(:user)
+
+ {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
+
+ {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+
+ {:ok, unlisted_activity} =
+ CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"})
+
+ {:ok, private_activity} =
+ CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+
+ activities =
+ ActivityPub.fetch_activities([], %{
+ "exclude_visibilities" => "direct",
+ "actor_id" => user.ap_id
+ })
+
+ assert public_activity in activities
+ assert unlisted_activity in activities
+ assert private_activity in activities
+ refute direct_activity in activities
+
+ activities =
+ ActivityPub.fetch_activities([], %{
+ "exclude_visibilities" => "unlisted",
+ "actor_id" => user.ap_id
+ })
+
+ assert public_activity in activities
+ refute unlisted_activity in activities
+ assert private_activity in activities
+ assert direct_activity in activities
+
+ activities =
+ ActivityPub.fetch_activities([], %{
+ "exclude_visibilities" => "private",
+ "actor_id" => user.ap_id
+ })
+
+ assert public_activity in activities
+ assert unlisted_activity in activities
+ refute private_activity in activities
+ assert direct_activity in activities
+
+ activities =
+ ActivityPub.fetch_activities([], %{
+ "exclude_visibilities" => "public",
+ "actor_id" => user.ap_id
+ })
+
+ refute public_activity in activities
+ assert unlisted_activity in activities
+ assert private_activity in activities
+ assert direct_activity in activities
+ end
+ end
+
describe "building a user from his ap id" do
test "it returns a user" do
user_id = "http://mastodon.example.org/users/admin"
assert [%{"id" => id}] = json_response(conn, 200)
assert id == to_string(post.id)
end
+
+ test "the user views their own timelines and excludes direct messages", %{conn: conn} do
+ user = insert(:user)
+ {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
+ {:ok, _direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_visibilities" => ["direct"]})
+
+ assert [%{"id" => id}] = json_response(conn, 200)
+ assert id == to_string(public_activity.id)
+ end
end
describe "followers" do
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
end
+ test "filters notifications using exclude_visibilities", %{conn: conn} do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, public_activity} =
+ CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "public"})
+
+ {:ok, direct_activity} =
+ CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "direct"})
+
+ {:ok, unlisted_activity} =
+ CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "unlisted"})
+
+ {:ok, private_activity} =
+ CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "private"})
+
+ conn = assign(conn, :user, user)
+
+ conn_res =
+ get(conn, "/api/v1/notifications", %{
+ exclude_visibilities: ["public", "unlisted", "private"]
+ })
+
+ assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
+ assert id == direct_activity.id
+
+ conn_res =
+ get(conn, "/api/v1/notifications", %{
+ exclude_visibilities: ["public", "unlisted", "direct"]
+ })
+
+ assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
+ assert id == private_activity.id
+
+ conn_res =
+ get(conn, "/api/v1/notifications", %{
+ exclude_visibilities: ["public", "private", "direct"]
+ })
+
+ assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
+ assert id == unlisted_activity.id
+
+ conn_res =
+ get(conn, "/api/v1/notifications", %{
+ exclude_visibilities: ["unlisted", "private", "direct"]
+ })
+
+ assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
+ assert id == public_activity.id
+ end
+
test "filters notifications using exclude_types", %{conn: conn} do
user = insert(:user)
other_user = insert(:user)
:ok
end
- test "the home timeline", %{conn: conn} do
- user = insert(:user)
- following = insert(:user)
+ describe "home" do
+ test "the home timeline", %{conn: conn} do
+ user = insert(:user)
+ following = insert(:user)
- {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"})
+ {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"})
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/timelines/home")
+ conn =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/timelines/home")
- assert Enum.empty?(json_response(conn, :ok))
+ assert Enum.empty?(json_response(conn, :ok))
- {:ok, user} = User.follow(user, following)
+ {:ok, user} = User.follow(user, following)
- conn =
- build_conn()
- |> assign(:user, user)
- |> get("/api/v1/timelines/home")
+ conn =
+ build_conn()
+ |> assign(:user, user)
+ |> get("/api/v1/timelines/home")
- assert [%{"content" => "test"}] = json_response(conn, :ok)
+ assert [%{"content" => "test"}] = json_response(conn, :ok)
+ end
+
+ test "the home timeline when the direct messages are excluded", %{conn: conn} do
+ user = insert(:user)
+ {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
+ {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+
+ {:ok, unlisted_activity} =
+ CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"})
+
+ {:ok, private_activity} =
+ CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/timelines/home", %{"exclude_visibilities" => ["direct"]})
+
+ assert status_ids = json_response(conn, :ok) |> Enum.map(& &1["id"])
+ assert public_activity.id in status_ids
+ assert unlisted_activity.id in status_ids
+ assert private_activity.id in status_ids
+ refute direct_activity.id in status_ids
+ end
end
describe "public" do