forked from YokaiRick/akkoma
Merge branch 'exclude-visibilities-for-timelines' into 'develop'
Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints See merge request pleroma/pleroma!1818
This commit is contained in:
commit
3b1bc079af
9 changed files with 297 additions and 44 deletions
|
@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
- Authentication: Added rate limit for password-authorized actions / login existence checks
|
- Authentication: Added rate limit for password-authorized actions / login existence checks
|
||||||
- Metadata Link: Atom syndication Feed
|
- Metadata Link: Atom syndication Feed
|
||||||
- Mix task to re-count statuses for all users (`mix pleroma.count_statuses`)
|
- 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
|
### Changed
|
||||||
- **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
|
- **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
|
||||||
|
|
|
@ -13,6 +13,7 @@ Some apps operate under the assumption that no more than 4 attachments can be re
|
||||||
## Timelines
|
## Timelines
|
||||||
|
|
||||||
Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users.
|
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
|
## Statuses
|
||||||
|
|
||||||
|
@ -84,6 +85,12 @@ Has these additional fields under the `pleroma` object:
|
||||||
|
|
||||||
- `is_seen`: true if the notification was read by the user
|
- `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`
|
## POST `/api/v1/statuses`
|
||||||
|
|
||||||
Additional parameters can be added to the JSON body/Form data:
|
Additional parameters can be added to the JSON body/Form data:
|
||||||
|
|
|
@ -17,6 +17,7 @@ defmodule Pleroma.Notification do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
require Logger
|
||||||
|
|
||||||
@type t :: %__MODULE__{}
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@ -34,43 +35,92 @@ def changeset(%Notification{} = notification, attrs) do
|
||||||
end
|
end
|
||||||
|
|
||||||
def for_user_query(user, opts \\ []) do
|
def for_user_query(user, opts \\ []) do
|
||||||
query =
|
Notification
|
||||||
Notification
|
|> where(user_id: ^user.id)
|
||||||
|> 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(
|
||||||
|
"(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)",
|
||||||
|
object.data,
|
||||||
|
a.data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> 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
|
||||||
|
|
||||||
|
@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
|
||||||
|> where(
|
|> where(
|
||||||
[n, a],
|
[n, a],
|
||||||
fragment(
|
not fragment(
|
||||||
"? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')",
|
"activity_visibility(?, ?, ?) = ANY (?)",
|
||||||
a.actor
|
a.actor,
|
||||||
|
a.recipients,
|
||||||
|
a.data,
|
||||||
|
^visibility
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> 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})
|
|
||||||
|
|
||||||
if opts[:with_muted] do
|
|
||||||
query
|
|
||||||
else
|
else
|
||||||
where(query, [n, a], a.actor not in ^user.info.muted_notifications)
|
Logger.error("Could not exclude visibility to #{visibility}")
|
||||||
|> where([n, a], a.actor not in ^user.info.blocks)
|
query
|
||||||
|> 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
|
end
|
||||||
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
|
def for_user(user, opts \\ %{}) do
|
||||||
user
|
user
|
||||||
|> for_user_query(opts)
|
|> for_user_query(opts)
|
||||||
|
|
|
@ -596,6 +596,49 @@ defp restrict_visibility(_query, %{visibility: visibility})
|
||||||
|
|
||||||
defp restrict_visibility(query, _visibility), do: query
|
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} = _),
|
defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _),
|
||||||
do: query
|
do: query
|
||||||
|
|
||||||
|
@ -960,6 +1003,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do
|
||||||
|> restrict_muted_reblogs(opts)
|
|> restrict_muted_reblogs(opts)
|
||||||
|> Activity.restrict_deactivated_users()
|
|> Activity.restrict_deactivated_users()
|
||||||
|> exclude_poll_votes(opts)
|
|> exclude_poll_votes(opts)
|
||||||
|
|> exclude_visibility(opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
|
def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
|
||||||
|
|
|
@ -71,6 +71,7 @@ def get_scheduled_activities(user, params \\ %{}) do
|
||||||
defp cast_params(params) do
|
defp cast_params(params) do
|
||||||
param_types = %{
|
param_types = %{
|
||||||
exclude_types: {:array, :string},
|
exclude_types: {:array, :string},
|
||||||
|
exclude_visibilities: {:array, :string},
|
||||||
reblogs: :boolean,
|
reblogs: :boolean,
|
||||||
with_muted: :boolean
|
with_muted: :boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,6 +87,66 @@ test "it restricts by the appropriate visibility" do
|
||||||
end
|
end
|
||||||
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
|
describe "building a user from his ap id" do
|
||||||
test "it returns a user" do
|
test "it returns a user" do
|
||||||
user_id = "http://mastodon.example.org/users/admin"
|
user_id = "http://mastodon.example.org/users/admin"
|
||||||
|
|
|
@ -237,6 +237,20 @@ test "filters user's statuses by a hashtag", %{conn: conn} do
|
||||||
assert [%{"id" => id}] = json_response(conn, 200)
|
assert [%{"id" => id}] = json_response(conn, 200)
|
||||||
assert id == to_string(post.id)
|
assert id == to_string(post.id)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "followers" do
|
describe "followers" do
|
||||||
|
|
|
@ -137,6 +137,57 @@ test "paginates notifications using min_id, since_id, max_id, and limit", %{conn
|
||||||
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
|
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
|
||||||
end
|
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
|
test "filters notifications using exclude_types", %{conn: conn} do
|
||||||
user = insert(:user)
|
user = insert(:user)
|
||||||
other_user = insert(:user)
|
other_user = insert(:user)
|
||||||
|
|
|
@ -20,27 +20,52 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "the home timeline", %{conn: conn} do
|
describe "home" do
|
||||||
user = insert(:user)
|
test "the home timeline", %{conn: conn} do
|
||||||
following = insert(:user)
|
user = insert(:user)
|
||||||
|
following = insert(:user)
|
||||||
|
|
||||||
{:ok, _activity} = CommonAPI.post(following, %{"status" => "test"})
|
{:ok, _activity} = CommonAPI.post(following, %{"status" => "test"})
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> assign(:user, user)
|
|> assign(:user, user)
|
||||||
|> get("/api/v1/timelines/home")
|
|> 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 =
|
conn =
|
||||||
build_conn()
|
build_conn()
|
||||||
|> assign(:user, user)
|
|> assign(:user, user)
|
||||||
|> get("/api/v1/timelines/home")
|
|> 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
|
end
|
||||||
|
|
||||||
describe "public" do
|
describe "public" do
|
||||||
|
|
Loading…
Reference in a new issue