Merge branch 'exclude-visibilities-for-timelines' into 'develop'
authorfeld <feld@feld.me>
Mon, 14 Oct 2019 19:40:40 +0000 (19:40 +0000)
committerfeld <feld@feld.me>
Mon, 14 Oct 2019 19:40:40 +0000 (19:40 +0000)
Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints

See merge request pleroma/pleroma!1818

CHANGELOG.md
docs/API/differences_in_mastoapi_responses.md
lib/pleroma/notification.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/mastodon_api/mastodon_api.ex
test/web/activity_pub/activity_pub_test.exs
test/web/mastodon_api/controllers/account_controller_test.exs
test/web/mastodon_api/controllers/notification_controller_test.exs
test/web/mastodon_api/controllers/timeline_controller_test.exs

index a9f124a8acfe467d334c9892324565c13254c535..e3ccfa4ea696cf33512175bd9a3d84709b964eb4 100644 (file)
@@ -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
 - 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)
index 21b29752914e5d877c88336ba25cb5ec156ad45c..aca0f5e0e9d01c1ec43398778a0feaedb1fea72f 100644 (file)
@@ -13,6 +13,7 @@ Some apps operate under the assumption that no more than 4 attachments can be re
 ## 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
 
@@ -84,6 +85,12 @@ Has these additional fields under the `pleroma` object:
 
 - `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:
index d94ae5971c5a7e8159ed3361d8bdcd402f43ea14..d145f8d5b5a42c8ba3b685196754e45a1eaf682e 100644 (file)
@@ -17,6 +17,7 @@ defmodule Pleroma.Notification do
 
   import Ecto.Query
   import Ecto.Changeset
+  require Logger
 
   @type t :: %__MODULE__{}
 
@@ -34,43 +35,92 @@ defmodule Pleroma.Notification do
   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)
index 364452b5d434d24317a2c260251cc7a7883e8444..1d34c4d7ef8e9f1049f4064458b70ce9246c4e10 100644 (file)
@@ -596,6 +596,49 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   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
 
@@ -960,6 +1003,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     |> restrict_muted_reblogs(opts)
     |> Activity.restrict_deactivated_users()
     |> exclude_poll_votes(opts)
+    |> exclude_visibility(opts)
   end
 
   def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
index ac01d1ff39a42639f4b457b780b5893e0429c3e5..d875a578840b4bfa6ec3af391c537aea05f7378c 100644 (file)
@@ -71,6 +71,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
   defp cast_params(params) do
     param_types = %{
       exclude_types: {:array, :string},
+      exclude_visibilities: {:array, :string},
       reblogs: :boolean,
       with_muted: :boolean
     }
index c9f2a92e78a8298afa58b96a065f9527487355b3..3a5a2f9840a4674ef51560e19c11875c5d656df6 100644 (file)
@@ -87,6 +87,66 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     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"
index 6a59c3d947f45983920fb25455ae3c09359fbf0f..745383757aa19e7ee8dd5ccc2f7e024f9f9186ba 100644 (file)
@@ -237,6 +237,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
       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
index e4137e92c92fb0d302813ebecb8f6ac0bc47ebc1..fa55a7cf927e33313dd00b074bcf607f1fb8f04b 100644 (file)
@@ -137,6 +137,57 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest 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)
index d3652d964b17ee2ddc22096ebc6bd5f811eabf58..fc45c25de98c893a7f31ca67282463348064c43e 100644 (file)
@@ -20,27 +20,52 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
     :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