MastodonAPI: Support poll notification
This commit is contained in:
parent
b221d77a6d
commit
0114754db2
14 changed files with 262 additions and 21 deletions
|
@ -552,6 +552,7 @@
|
|||
mailer: 10,
|
||||
transmogrifier: 20,
|
||||
scheduled_activities: 10,
|
||||
poll_notifications: 10,
|
||||
background: 5,
|
||||
remote_fetcher: 2,
|
||||
attachments_cleanup: 1,
|
||||
|
|
|
@ -72,6 +72,7 @@ def unread_notifications_count(%User{id: user_id}) do
|
|||
pleroma:emoji_reaction
|
||||
pleroma:report
|
||||
reblog
|
||||
poll
|
||||
}
|
||||
|
||||
def changeset(%Notification{} = notification, attrs) do
|
||||
|
@ -379,7 +380,7 @@ defp do_create_notifications(%Activity{} = activity, options) 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 @@ defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) 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 @@ def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true)
|
|||
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 @@ def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) 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 @@ def skip?(%Activity{} = activity, %User{} = user) 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 @@ def skip?(:invisible, %Activity{} = activity, _) 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 @@ def skip?(
|
|||
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 @@ def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity,
|
|||
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 @@ def skip?(:filtered, activity, user) 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)
|
||||
|
|
|
@ -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 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
|
|||
{: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 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
|
|||
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] || %{}
|
||||
|
|
|
@ -195,7 +195,8 @@ defp notification_type do
|
|||
"pleroma:chat_mention",
|
||||
"pleroma:report",
|
||||
"move",
|
||||
"follow_request"
|
||||
"follow_request",
|
||||
"poll"
|
||||
],
|
||||
description: """
|
||||
The type of event that resulted in the notification.
|
||||
|
|
|
@ -50,6 +50,7 @@ def index(conn, %{account_id: account_id} = params) do
|
|||
favourite
|
||||
move
|
||||
pleroma:emoji_reaction
|
||||
poll
|
||||
}
|
||||
def index(%{assigns: %{user: user}} = conn, params) do
|
||||
params =
|
||||
|
|
|
@ -112,6 +112,9 @@ def render(
|
|||
"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)
|
||||
|
|
|
@ -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)
|
||||
|
|
43
lib/pleroma/workers/poll_worker.ex
Normal file
43
lib/pleroma/workers/poll_worker.ex
Normal file
|
@ -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
|
|
@ -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
|
|
@ -129,6 +129,19 @@ test "does not create a notification for subscribed users if status is a reply"
|
|||
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,
|
||||
|
|
|
@ -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 @@ test "it posts a poll" 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
|
||||
|
||||
|
|
|
@ -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 @@ test "scheduled poll", %{conn: conn} 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
|
||||
|
|
|
@ -196,6 +196,27 @@ test "EmojiReact notification" 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)
|
||||
|
|
|
@ -201,6 +201,36 @@ def tombstone_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 @@ def report_activity_factory(attrs \\ %{}) 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}"),
|
||||
|
|
Loading…
Reference in a new issue