MastodonAPI: Support poll notification
authorAlex Gleason <alex@alexgleason.me>
Sun, 18 Jul 2021 01:35:35 +0000 (20:35 -0500)
committerAlex Gleason <alex@alexgleason.me>
Sun, 18 Jul 2021 03:19:38 +0000 (22:19 -0500)
14 files changed:
config/config.exs
lib/pleroma/notification.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/api_spec/operations/notification_operation.ex
lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
lib/pleroma/web/mastodon_api/views/notification_view.ex
lib/pleroma/web/push/subscription.ex
lib/pleroma/workers/poll_worker.ex [new file with mode: 0644]
priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs [new file with mode: 0644]
test/pleroma/notification_test.exs
test/pleroma/web/common_api_test.exs
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
test/pleroma/web/mastodon_api/views/notification_view_test.exs
test/support/factory.ex

index 66aee3264a0fa0a305a6748ceba77d1b846d1369..e58dafa74e66d0cb740d2d4e5abd071c1f49aa62 100644 (file)
@@ -552,6 +552,7 @@ config :pleroma, Oban,
     mailer: 10,
     transmogrifier: 20,
     scheduled_activities: 10,
+    poll_notifications: 10,
     background: 5,
     remote_fetcher: 2,
     attachments_cleanup: 1,
index 7efbdc49afe1811a145a78cc548ad0dd54935849..43a0e8f49aaac708e64d102e495cc6225bbac0e1 100644 (file)
@@ -72,6 +72,7 @@ defmodule Pleroma.Notification do
     pleroma:emoji_reaction
     pleroma:report
     reblog
+    poll
   }
 
   def changeset(%Notification{} = notification, attrs) do
@@ -379,7 +380,7 @@ defmodule Pleroma.Notification do
     notifications =
       Enum.map(potential_receivers, fn user ->
         do_send = do_send && user in enabled_receivers
-        create_notification(activity, user, do_send)
+        create_notification(activity, user, do_send: do_send)
       end)
       |> Enum.reject(&is_nil/1)
 
@@ -435,15 +436,18 @@ defmodule Pleroma.Notification do
   end
 
   # TODO move to sql, too.
-  def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do
-    unless skip?(activity, user) do
+  def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do
+    do_send = Keyword.get(opts, :do_send, true)
+    type = Keyword.get(opts, :type, type_from_activity(activity))
+
+    unless skip?(activity, user, opts) do
       {:ok, %{notification: notification}} =
         Multi.new()
         |> Multi.insert(:notification, %Notification{
           user_id: user.id,
           activity: activity,
           seen: mark_as_read?(activity, user),
-          type: type_from_activity(activity)
+          type: type
         })
         |> Marker.multi_set_last_read_id(user, "notifications")
         |> Repo.transaction()
@@ -457,6 +461,26 @@ defmodule Pleroma.Notification do
     end
   end
 
+  def create_poll_notifications(%Activity{} = activity) do
+    with %Object{data: %{"type" => "Question", "actor" => actor} = data} <-
+           Object.normalize(activity) do
+      voters =
+        case data do
+          %{"voters" => voters} when is_list(voters) -> voters
+          _ -> []
+        end
+
+      notifications =
+        Enum.map([actor | voters], fn ap_id ->
+          with %User{} = user <- User.get_by_ap_id(ap_id) do
+            create_notification(activity, user, type: "poll")
+          end
+        end)
+
+      {:ok, notifications}
+    end
+  end
+
   @doc """
   Returns a tuple with 2 elements:
     {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)}
@@ -572,8 +596,10 @@ defmodule Pleroma.Notification do
     Enum.uniq(ap_ids) -- thread_muter_ap_ids
   end
 
-  @spec skip?(Activity.t(), User.t()) :: boolean()
-  def skip?(%Activity{} = activity, %User{} = user) do
+  def skip?(activity, user, opts \\ [])
+
+  @spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean()
+  def skip?(%Activity{} = activity, %User{} = user, opts) do
     [
       :self,
       :invisible,
@@ -581,17 +607,21 @@ defmodule Pleroma.Notification do
       :recently_followed,
       :filtered
     ]
-    |> Enum.find(&skip?(&1, activity, user))
+    |> Enum.find(&skip?(&1, activity, user, opts))
   end
 
-  def skip?(_, _), do: false
+  def skip?(_activity, _user, _opts), do: false
 
-  @spec skip?(atom(), Activity.t(), User.t()) :: boolean()
-  def skip?(:self, %Activity{} = activity, %User{} = user) do
-    activity.data["actor"] == user.ap_id
+  @spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean()
+  def skip?(:self, %Activity{} = activity, %User{} = user, opts) do
+    cond do
+      opts[:type] == "poll" -> false
+      activity.data["actor"] == user.ap_id -> true
+      true -> false
+    end
   end
 
-  def skip?(:invisible, %Activity{} = activity, _) do
+  def skip?(:invisible, %Activity{} = activity, _user, _opts) do
     actor = activity.data["actor"]
     user = User.get_cached_by_ap_id(actor)
     User.invisible?(user)
@@ -600,7 +630,8 @@ defmodule Pleroma.Notification do
   def skip?(
         :block_from_strangers,
         %Activity{} = activity,
-        %User{notification_settings: %{block_from_strangers: true}} = user
+        %User{notification_settings: %{block_from_strangers: true}} = user,
+        _opts
       ) do
     actor = activity.data["actor"]
     follower = User.get_cached_by_ap_id(actor)
@@ -608,7 +639,12 @@ defmodule Pleroma.Notification do
   end
 
   # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
-  def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, %User{} = user) do
+  def skip?(
+        :recently_followed,
+        %Activity{data: %{"type" => "Follow"}} = activity,
+        %User{} = user,
+        _opts
+      ) do
     actor = activity.data["actor"]
 
     Notification.for_user(user)
@@ -618,9 +654,10 @@ defmodule Pleroma.Notification do
     end)
   end
 
-  def skip?(:filtered, %{data: %{"type" => type}}, _) when type in ["Follow", "Move"], do: false
+  def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"],
+    do: false
 
-  def skip?(:filtered, activity, user) do
+  def skip?(:filtered, activity, user, _opts) do
     object = Object.normalize(activity, fetch: false)
 
     cond do
@@ -638,7 +675,7 @@ defmodule Pleroma.Notification do
     end
   end
 
-  def skip?(_, _, _), do: false
+  def skip?(_type, _activity, _user, _opts), do: false
 
   def mark_as_read?(activity, target_user) do
     user = Activity.user_actor(activity)
index 5b45e2ca1dca30c5488b53c4477d73958c25aeb3..200311ee91f7f62d15f7b660d04e5fc03c2fa25c 100644 (file)
@@ -24,6 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   alias Pleroma.Web.Streamer
   alias Pleroma.Web.WebFinger
   alias Pleroma.Workers.BackgroundWorker
+  alias Pleroma.Workers.PollWorker
 
   import Ecto.Query
   import Pleroma.Web.ActivityPub.Utils
@@ -284,6 +285,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
          {:ok, _actor} <- increase_note_count_if_public(actor, activity),
          _ <- notify_and_stream(activity),
+         :ok <- maybe_schedule_poll_notifications(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -298,6 +300,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
+  defp maybe_schedule_poll_notifications(activity) do
+    PollWorker.schedule_poll_end(activity)
+    :ok
+  end
+
   @spec listen(map()) :: {:ok, Activity.t()} | {:error, any()}
   def listen(%{to: to, actor: actor, context: context, object: object} = params) do
     additional = params[:additional] || %{}
index ec88eabe1f28d65f57bd590de6eaab77b811a2bc..e4ce42f1cf91540c2c0963950b0b255259551db9 100644 (file)
@@ -195,7 +195,8 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
         "pleroma:chat_mention",
         "pleroma:report",
         "move",
-        "follow_request"
+        "follow_request",
+        "poll"
       ],
       description: """
       The type of event that resulted in the notification.
index 647ba661e7b917151eb904e78a1644dedc0f9eb6..002d6b2cea48762b61eabc927cde7adfacc7da57 100644 (file)
@@ -50,6 +50,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
     favourite
     move
     pleroma:emoji_reaction
+    poll
   }
   def index(%{assigns: %{user: user}} = conn, params) do
     params =
index df9bedfed478b894a1b22bde6eb7912af7b3972b..35c636d4e8c24679ae5be3a5c87ecbd703d49272 100644 (file)
@@ -112,6 +112,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
       "move" ->
         put_target(response, activity, reading_user, %{})
 
+      "poll" ->
+        put_status(response, activity, reading_user, status_render_opts)
+
       "pleroma:emoji_reaction" ->
         response
         |> put_status(parent_activity_fn.(), reading_user, status_render_opts)
index 4f6c9bc9f731397794212ffb85103cc888cffe73..35bf2e2236dc61f382e5684ed3ef7946a9e9ff4e 100644 (file)
@@ -26,7 +26,7 @@ defmodule Pleroma.Web.Push.Subscription do
   end
 
   # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
-  @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention pleroma:emoji_reaction]a
+  @supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a
 
   defp alerts(%{data: %{alerts: alerts}}) do
     alerts = Map.take(alerts, @supported_alert_types)
diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex
new file mode 100644 (file)
index 0000000..caec89c
--- /dev/null
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.PollWorker do
+  @moduledoc """
+  Generates notifications when a poll ends.
+  """
+  use Pleroma.Workers.WorkerHelper, queue: "poll_notifications"
+
+  alias Pleroma.Activity
+  alias Pleroma.Notification
+  alias Pleroma.Object
+
+  @impl Oban.Worker
+  def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do
+    with %Activity{} = activity <- find_poll_activity(activity_id) do
+      Notification.create_poll_notifications(activity)
+    end
+  end
+
+  defp find_poll_activity(activity_id) do
+    with nil <- Activity.get_by_id(activity_id) do
+      {:error, :poll_activity_not_found}
+    end
+  end
+
+  def schedule_poll_end(%Activity{data: %{"type" => "Create"}, id: activity_id} = activity) do
+    with %Object{data: %{"type" => "Question", "closed" => closed}} <- Object.normalize(activity),
+         {:ok, end_time} <- NaiveDateTime.from_iso8601(closed) do
+      %{
+        op: "poll_end",
+        activity_id: activity_id
+      }
+      |> new(scheduled_at: end_time)
+      |> Oban.insert()
+    else
+      _ -> {:error, activity}
+    end
+  end
+
+  def schedule_poll_end(activity), do: {:error, activity}
+end
diff --git a/priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs b/priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs
new file mode 100644 (file)
index 0000000..9abf40b
--- /dev/null
@@ -0,0 +1,49 @@
+defmodule Pleroma.Repo.Migrations.AddPollToNotificationsEnum do
+  use Ecto.Migration
+
+  @disable_ddl_transaction true
+
+  def up do
+    """
+    alter type notification_type add value 'poll'
+    """
+    |> execute()
+  end
+
+  def down do
+    alter table(:notifications) do
+      modify(:type, :string)
+    end
+
+    """
+    delete from notifications where type = 'poll'
+    """
+    |> execute()
+
+    """
+    drop type if exists notification_type
+    """
+    |> execute()
+
+    """
+    create type notification_type as enum (
+      'follow',
+      'follow_request',
+      'mention',
+      'move',
+      'pleroma:emoji_reaction',
+      'pleroma:chat_mention',
+      'reblog',
+      'favourite',
+      'pleroma:report'
+    )
+    """
+    |> execute()
+
+    """
+    alter table notifications
+    alter column type type notification_type using (type::notification_type)
+    """
+    |> execute()
+  end
+end
index abf1b04109255ba42a70031cdb6a9167e93f61c8..0d2cacb57a6c465ff930b08f7f0b023d584c560e 100644 (file)
@@ -129,6 +129,19 @@ defmodule Pleroma.NotificationTest do
     end
   end
 
+  test "create_poll_notifications/1" do
+    [user1, user2, user3, _, _] = insert_list(5, :user)
+    question = insert(:question, user: user1)
+    activity = insert(:question_activity, question: question)
+
+    {:ok, _, _} = CommonAPI.vote(user2, question, [0])
+    {:ok, _, _} = CommonAPI.vote(user3, question, [1])
+
+    {:ok, notifications} = Notification.create_poll_notifications(activity)
+
+    assert [user1.id, user3.id, user2.id] == Enum.map(notifications, & &1.user_id)
+  end
+
   describe "CommonApi.post/2 notification-related functionality" do
     test_with_mock "creates but does NOT send notification to blocker user",
                    Push,
index adfe58def268babb03fbdeb771a956dc00bfaf66..ace0131526ffca2d856ab0e70429daf07a419820 100644 (file)
@@ -18,6 +18,7 @@ defmodule Pleroma.Web.CommonAPITest do
   alias Pleroma.Web.ActivityPub.Visibility
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.CommonAPI
+  alias Pleroma.Workers.PollWorker
 
   import Pleroma.Factory
   import Mock
@@ -43,6 +44,12 @@ defmodule Pleroma.Web.CommonAPITest do
 
       assert object.data["type"] == "Question"
       assert object.data["oneOf"] |> length() == 2
+
+      assert_enqueued(
+        worker: PollWorker,
+        args: %{op: "poll_end", activity_id: activity.id},
+        scheduled_at: NaiveDateTime.from_iso8601!(object.data["closed"])
+      )
     end
   end
 
index e76c2760df68f0dc1fd6a48ea221c49576f895e7..4288971c97a72c529d509871c50a2f7d59fc9cf4 100644 (file)
@@ -15,6 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.CommonAPI
+  alias Pleroma.Workers.ScheduledActivityWorker
 
   import Pleroma.Factory
 
@@ -685,11 +686,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
         |> json_response_and_validate_schema(200)
 
       assert {:ok, %{id: activity_id}} =
-               perform_job(Pleroma.Workers.ScheduledActivityWorker, %{
+               perform_job(ScheduledActivityWorker, %{
                  activity_id: scheduled_id
                })
 
-      assert Repo.all(Oban.Job) == []
+      refute_enqueued(worker: ScheduledActivityWorker)
 
       object =
         Activity
index 496a688d1f79c6908bb5d8e1ac4dfb9a210c3814..8070c03c950032c64f2b833fd3d14cb638a47937 100644 (file)
@@ -196,6 +196,27 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
     test_notifications_rendering([notification], user, [expected])
   end
 
+  test "Poll notification" do
+    user = insert(:user)
+    activity = insert(:question_activity, user: user)
+    {:ok, [notification]} = Notification.create_poll_notifications(activity)
+
+    expected = %{
+      id: to_string(notification.id),
+      pleroma: %{is_seen: false, is_muted: false},
+      type: "poll",
+      account:
+        AccountView.render("show.json", %{
+          user: user,
+          for: user
+        }),
+      status: StatusView.render("show.json", %{activity: activity, for: user}),
+      created_at: Utils.to_masto_date(notification.inserted_at)
+    }
+
+    test_notifications_rendering([notification], user, [expected])
+  end
+
   test "Report notification" do
     reporting_user = insert(:user)
     reported_user = insert(:user)
index af4fff45b947c771e5d622b1ff7af73cf9108388..e87e54e7bdb01cb266550a4d957547755bb2e1ad 100644 (file)
@@ -201,6 +201,36 @@ defmodule Pleroma.Factory do
     }
   end
 
+  def question_factory(attrs \\ %{}) do
+    user = attrs[:user] || insert(:user)
+
+    data = %{
+      "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
+      "type" => "Question",
+      "actor" => user.ap_id,
+      "attachment" => [],
+      "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+      "cc" => [user.follower_address],
+      "context" => Pleroma.Web.ActivityPub.Utils.generate_context_id(),
+      "oneOf" => [
+        %{
+          "type" => "Note",
+          "name" => "chocolate",
+          "replies" => %{"totalItems" => 0, "type" => "Collection"}
+        },
+        %{
+          "type" => "Note",
+          "name" => "vanilla",
+          "replies" => %{"totalItems" => 0, "type" => "Collection"}
+        }
+      ]
+    }
+
+    %Pleroma.Object{
+      data: merge_attributes(data, Map.get(attrs, :data, %{}))
+    }
+  end
+
   def direct_note_activity_factory do
     dm = insert(:direct_note)
 
@@ -350,6 +380,33 @@ defmodule Pleroma.Factory do
     }
   end
 
+  def question_activity_factory(attrs \\ %{}) do
+    user = attrs[:user] || insert(:user)
+    question = attrs[:question] || insert(:question, user: user)
+
+    data_attrs = attrs[:data_attrs] || %{}
+    attrs = Map.drop(attrs, [:user, :question, :data_attrs])
+
+    data =
+      %{
+        "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
+        "type" => "Create",
+        "actor" => question.data["actor"],
+        "to" => question.data["to"],
+        "object" => question.data["id"],
+        "published" => DateTime.utc_now() |> DateTime.to_iso8601(),
+        "context" => question.data["context"]
+      }
+      |> Map.merge(data_attrs)
+
+    %Pleroma.Activity{
+      data: data,
+      actor: data["actor"],
+      recipients: data["to"]
+    }
+    |> Map.merge(attrs)
+  end
+
   def oauth_app_factory do
     %Pleroma.Web.OAuth.App{
       client_name: sequence(:client_name, &"Some client #{&1}"),