From 9f5e3a39ecf2a061828b062a12140a7ef6d96182 Mon Sep 17 00:00:00 2001
From: Thomas Citharel
Date: Thu, 6 May 2021 12:27:04 +0200
Subject: [PATCH] Add Push notifications backend support
Signed-off-by: Thomas Citharel
---
lib/graphql/resolvers/push_subscription.ex | 49 ++++++++++++
lib/graphql/schema/users/push_subscription.ex | 34 +++++++++
lib/mobilizon/users/push_subscription.ex | 49 ++++++++++++
lib/mobilizon/users/setting.ex | 9 ++-
lib/mobilizon/users/users.ex | 76 ++++++++++++++++++-
lib/service/notifier/email.ex | 63 +++++++++++++--
lib/service/notifier/notifier.ex | 6 +-
lib/service/notifier/push.ex | 22 +++---
lib/service/workers/activity_builder.ex | 4 +-
lib/web/email/activity.ex | 5 +-
.../email/email_direct_activity.html.eex | 2 +
.../email/email_direct_activity.text.eex | 2 +-
...on_and_last_notification_date_settings.exs | 10 +++
...6080303_create_user_push_subscriptions.exs | 16 ++++
14 files changed, 321 insertions(+), 26 deletions(-)
create mode 100644 lib/graphql/resolvers/push_subscription.ex
create mode 100644 lib/graphql/schema/users/push_subscription.ex
create mode 100644 lib/mobilizon/users/push_subscription.ex
create mode 100644 priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs
create mode 100644 priv/repo/migrations/20210506080303_create_user_push_subscriptions.exs
diff --git a/lib/graphql/resolvers/push_subscription.ex b/lib/graphql/resolvers/push_subscription.ex
new file mode 100644
index 000000000..f8b40a9ed
--- /dev/null
+++ b/lib/graphql/resolvers/push_subscription.ex
@@ -0,0 +1,49 @@
+defmodule Mobilizon.GraphQL.Resolvers.PushSubscription do
+ @moduledoc """
+ Handles the push subscriptions-related GraphQL calls.
+ """
+
+ alias Mobilizon.Users
+ alias Mobilizon.Storage.Page
+ alias Mobilizon.Users.{PushSubscription, User}
+
+ @doc """
+ List all of an user's registered push subscriptions
+ """
+ def list_user_push_subscriptions(_parent, %{page: page, limit: limit}, %{
+ context: %{current_user: %User{id: user_id}}
+ }) do
+ %Page{} = page = Users.list_user_push_subscriptions(user_id, page, limit)
+ {:ok, page}
+ end
+
+ def list_user_push_subscriptions(_parent, _args, _resolution), do: {:error, :unauthenticated}
+
+ @doc """
+ Register a push subscription
+ """
+ def register_push_subscription(_parent, args, %{
+ context: %{current_user: %User{id: user_id}}
+ }) do
+ Users.create_push_subscription(Map.put(args, :user_id, user_id))
+ end
+
+ @spec unregister_push_subscription(map(), map(), map()) ::
+ {:ok, PushSubscription.t()} | {:error, :unauthorized} | {:error, :not_found}
+ def unregister_push_subscription(_parent, %{id: push_subscription_id}, %{
+ context: %{current_user: %User{id: user_id}}
+ }) do
+ with %PushSubscription{user: %User{id: push_subscription_user_id}} = push_subscription <-
+ Users.get_push_subscription(push_subscription_id),
+ {:user_owns_push_subscription, true} <-
+ {:user_owns_push_subscription, push_subscription_user_id == user_id} do
+ Users.delete_push_subscription(push_subscription)
+ else
+ {:user_owns_push_subscription, false} ->
+ {:error, :unauthorized}
+
+ nil ->
+ {:error, :not_found}
+ end
+ end
+end
diff --git a/lib/graphql/schema/users/push_subscription.ex b/lib/graphql/schema/users/push_subscription.ex
new file mode 100644
index 000000000..43376b44f
--- /dev/null
+++ b/lib/graphql/schema/users/push_subscription.ex
@@ -0,0 +1,34 @@
+defmodule Mobilizon.GraphQL.Schema.Users.PushSubscription do
+ @moduledoc """
+ Schema representation for PushSubscription
+ """
+ use Absinthe.Schema.Notation
+ alias Mobilizon.GraphQL.Resolvers.PushSubscription
+
+ @desc """
+ An object representing the keys for a push subscription
+ """
+ input_object :push_subscription_keys do
+ field(:p256dh, non_null(:string))
+ field(:auth, non_null(:string))
+ end
+
+ object :push_queries do
+ field :list_push_subscriptions, :paginated_push_subscription_list do
+ resolve(&PushSubscription.list_user_push_subscriptions/3)
+ end
+ end
+
+ object :push_mutations do
+ field :register_push_mutation, :string do
+ arg(:endpoint, non_null(:string))
+ arg(:keys, non_null(:push_subscription_keys))
+ resolve(&PushSubscription.register_push_subscription/3)
+ end
+
+ field :unregister_push_mutation, :string do
+ arg(:id, non_null(:id))
+ resolve(&PushSubscription.unregister_push_subscription/3)
+ end
+ end
+end
diff --git a/lib/mobilizon/users/push_subscription.ex b/lib/mobilizon/users/push_subscription.ex
new file mode 100644
index 000000000..0ea7fa777
--- /dev/null
+++ b/lib/mobilizon/users/push_subscription.ex
@@ -0,0 +1,49 @@
+defmodule Mobilizon.Users.PushSubscription do
+ use Ecto.Schema
+ alias Mobilizon.Users.User
+ import Ecto.Changeset
+
+ schema "user_push_subscriptions" do
+ field(:digest, :string)
+ belongs_to(:user, User)
+
+ embeds_one :data, Data, on_replace: :delete do
+ field(:endpoint, :string)
+
+ embeds_one :keys, Keys, on_replace: :delete do
+ field(:auth, :string)
+ field(:p256dh, :string)
+ end
+ end
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(push_subscription, attrs) do
+ push_subscription
+ |> cast(attrs, [:user_id])
+ |> cast_embed(:data, with: &cast_data/2)
+ |> put_change(:digest, compute_digest(attrs.data))
+ |> validate_required([:digest, :user_id, :data])
+ end
+
+ defp cast_data(schema, attrs) do
+ schema
+ |> cast(attrs, [:endpoint])
+ |> cast_embed(:keys, with: &cast_keys/2)
+ |> validate_required([:endpoint, :keys])
+ end
+
+ defp cast_keys(schema, attrs) do
+ schema
+ |> cast(attrs, [:auth, :p256dh])
+ |> validate_required([:auth, :p256dh])
+ end
+
+ defp compute_digest(data) do
+ :sha256
+ |> :crypto.hash(data)
+ |> Base.encode16()
+ end
+end
diff --git a/lib/mobilizon/users/setting.ex b/lib/mobilizon/users/setting.ex
index 38d762818..b4f7d01df 100644
--- a/lib/mobilizon/users/setting.ex
+++ b/lib/mobilizon/users/setting.ex
@@ -14,6 +14,8 @@ defmodule Mobilizon.Users.Setting do
notification_before_event: boolean,
notification_pending_participation: NotificationPendingNotificationDelay.t(),
notification_pending_membership: NotificationPendingNotificationDelay.t(),
+ group_notifications: NotificationPendingNotificationDelay.t(),
+ last_notification_sent: DateTime.t(),
user: User.t()
}
@@ -25,7 +27,9 @@ defmodule Mobilizon.Users.Setting do
:notification_each_week,
:notification_before_event,
:notification_pending_participation,
- :notification_pending_membership
+ :notification_pending_membership,
+ :group_notifications,
+ :last_notification_sent
]
@attrs @required_attrs ++ @optional_attrs
@@ -47,6 +51,9 @@ defmodule Mobilizon.Users.Setting do
default: :one_day
)
+ field(:group_notifications, NotificationPendingNotificationDelay, default: :one_day)
+ field(:last_notification_sent, :utc_datetime)
+
embeds_one :location, Location, on_replace: :update, primary_key: false do
field(:name, :string)
field(:range, :integer)
diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex
index b9d5ce779..30e04f18d 100644
--- a/lib/mobilizon/users/users.ex
+++ b/lib/mobilizon/users/users.ex
@@ -13,7 +13,7 @@ defmodule Mobilizon.Users do
alias Mobilizon.{Crypto, Events}
alias Mobilizon.Events.FeedToken
alias Mobilizon.Storage.{Page, Repo}
- alias Mobilizon.Users.{Setting, User}
+ alias Mobilizon.Users.{PushSubscription, Setting, User}
defenum(UserRole, :user_role, [:administrator, :moderator, :user])
@@ -405,6 +405,80 @@ defmodule Mobilizon.Users do
Setting.changeset(setting, %{})
end
+ @doc """
+ Get a paginated list of all of a user's subscriptions
+ """
+ @spec list_user_push_subscriptions(String.t() | integer(), integer() | nil, integer() | nil) ::
+ Page.t()
+ def list_user_push_subscriptions(user_id, page \\ nil, limit \\ nil) do
+ PushSubscription
+ |> where([p], p.user_id == ^user_id)
+ |> preload([:user])
+ |> Page.build_page(page, limit)
+ end
+
+ @doc """
+ Get a push subscription by their ID
+ """
+ @spec get_push_subscription(String.t() | integer()) :: PushSubscription.t() | nil
+ def get_push_subscription(push_subscription_id) do
+ PushSubscription
+ |> Repo.get(push_subscription_id)
+ |> Repo.preload([:user])
+ end
+
+ @doc """
+ Creates a push subscription.
+
+ ## Examples
+
+ iex> create_push_subscription(%{field: value})
+ {:ok, %PushSubscription{}}
+
+ iex> create_push_subscription(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_push_subscription(attrs \\ %{}) do
+ %PushSubscription{}
+ |> PushSubscription.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a push subscription.
+
+ ## Examples
+
+ iex> update_push_subscription(push_subscription, %{field: new_value})
+ {:ok, %PushSubscription{}}
+
+ iex> update_push_subscription(push_subscription, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_push_subscription(%PushSubscription{} = push_subscription, attrs) do
+ push_subscription
+ |> PushSubscription.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a push subscription.
+
+ ## Examples
+
+ iex> delete_push_subscription(push_subscription)
+ {:ok, %PushSubscription{}}
+
+ iex> delete_push_subscription(push_subscription)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_push_subscription(%PushSubscription{} = push_subscription) do
+ Repo.delete(push_subscription)
+ end
+
@spec user_by_email_query(String.t(), boolean | nil, boolean()) :: Ecto.Query.t()
defp user_by_email_query(email, activated, unconfirmed) do
User
diff --git a/lib/service/notifier/email.ex b/lib/service/notifier/email.ex
index a1f01a6fc..2db6aa0b9 100644
--- a/lib/service/notifier/email.ex
+++ b/lib/service/notifier/email.ex
@@ -3,10 +3,10 @@ defmodule Mobilizon.Service.Notifier.Email do
Email notifier
"""
alias Mobilizon.Activities.Activity
- alias Mobilizon.Config
+ alias Mobilizon.{Config, Users}
alias Mobilizon.Service.Notifier
alias Mobilizon.Service.Notifier.Email
- alias Mobilizon.Users.User
+ alias Mobilizon.Users.{NotificationPendingNotificationDelay, Setting, User}
alias Mobilizon.Web.Email.Activity, as: EmailActivity
alias Mobilizon.Web.Email.Mailer
@@ -18,14 +18,61 @@ defmodule Mobilizon.Service.Notifier.Email do
end
@impl Notifier
- def send(%User{} = user, %Activity{} = activity) do
- Email.send(user, [activity])
+ def send(%User{} = user, %Activity{} = activity, options) do
+ Email.send(user, [activity], options)
end
@impl Notifier
- def send(%User{email: email, locale: locale}, activities) when is_list(activities) do
- email
- |> EmailActivity.direct_activity(activities, locale)
- |> Mailer.send_email()
+ def send(%User{email: email, locale: locale} = user, activities, options)
+ when is_list(activities) do
+ if can_send?(user) do
+ email
+ |> EmailActivity.direct_activity(activities, Keyword.put(options, :locale, locale))
+ |> Mailer.send_email()
+
+ save_last_notification_time(user)
+ {:ok, :sent}
+ else
+ {:ok, :skipped}
+ end
+ end
+
+ @type notification_type ::
+ :group_notifications
+ | :notification_pending_participation
+ | :notification_pending_membership
+
+ @spec user_notification_delay(User.t(), notification_type()) ::
+ NotificationPendingNotificationDelay.t()
+ defp user_notification_delay(%User{} = user, type \\ :group_notifications) do
+ Map.from_struct(user.settings)[type]
+ end
+
+ @spec can_send?(User.t()) :: boolean()
+ defp can_send?(%User{settings: %Setting{last_notification_sent: last_notification_sent}} = user) do
+ last_notification_sent_or_default = last_notification_sent || DateTime.utc_now()
+ notification_delay = user_notification_delay(user)
+ diff = DateTime.diff(DateTime.utc_now(), last_notification_sent_or_default)
+
+ cond do
+ notification_delay == :none -> false
+ is_nil(last_notification_sent) -> true
+ notification_delay == :direct -> true
+ notification_delay == :one_hour -> diff >= 60 * 60
+ notification_delay == :one_day -> diff >= 24 * 60 * 60
+ end
+ end
+
+ @spec save_last_notification_time(User.t()) :: {:ok, Setting.t()} | {:error, Ecto.Changeset.t()}
+ defp save_last_notification_time(%User{id: user_id}) do
+ attrs = %{user_id: user_id, last_notification_sent: DateTime.utc_now()}
+
+ case Users.get_setting(user_id) do
+ nil ->
+ Users.create_setting(attrs)
+
+ %Setting{} = setting ->
+ Users.update_setting(setting, attrs)
+ end
end
end
diff --git a/lib/service/notifier/notifier.ex b/lib/service/notifier/notifier.ex
index 56b57d17d..9c9140ae3 100644
--- a/lib/service/notifier/notifier.ex
+++ b/lib/service/notifier/notifier.ex
@@ -14,12 +14,12 @@ defmodule Mobilizon.Service.Notifier do
@doc """
Sends one or multiple notifications from an activity
"""
- @callback send(User.t(), Activity.t()) :: {:ok, any()} | {:error, String.t()}
+ @callback send(User.t(), Activity.t(), Keyword.t()) :: {:ok, any()} | {:error, String.t()}
- @callback send(User.t(), list(Activity.t())) :: {:ok, any()} | {:error, String.t()}
+ @callback send(User.t(), list(Activity.t()), Keyword.t()) :: {:ok, any()} | {:error, String.t()}
def notify(%User{} = user, %Activity{} = activity, opts \\ []) do
- Enum.each(providers(opts), & &1.send(user, activity))
+ Enum.each(providers(opts), & &1.send(user, activity, opts))
end
@spec providers(Keyword.t()) :: list()
diff --git a/lib/service/notifier/push.ex b/lib/service/notifier/push.ex
index 532aa4029..e9b7c0b94 100644
--- a/lib/service/notifier/push.ex
+++ b/lib/service/notifier/push.ex
@@ -3,9 +3,10 @@ defmodule Mobilizon.Service.Notifier.Push do
WebPush notifier
"""
alias Mobilizon.Activities.Activity
- alias Mobilizon.Config
+ alias Mobilizon.{Config, Users}
alias Mobilizon.Service.Notifier
alias Mobilizon.Service.Notifier.Push
+ alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
@behaviour Notifier
@@ -16,17 +17,14 @@ defmodule Mobilizon.Service.Notifier.Push do
end
@impl Notifier
- def send(%User{} = _user, %Activity{} = activity) do
- # Get user's subscriptions
- activity
- |> payload()
-
- # |> WebPushEncryption.send_web_push()
+ def send(%User{id: user_id} = _user, %Activity{} = activity, _opts) do
+ %Page{elements: subscriptions} = Users.list_user_push_subscriptions(user_id, 1, 100)
+ Enum.each(subscriptions, &send_subscription(activity, &1))
end
@impl Notifier
- def send(%User{} = user, activities) when is_list(activities) do
- Enum.each(activities, &Push.send(user, &1))
+ def send(%User{} = user, activities, opts) when is_list(activities) do
+ Enum.each(activities, &Push.send(user, &1, opts))
end
defp payload(%Activity{subject: subject}) do
@@ -35,4 +33,10 @@ defmodule Mobilizon.Service.Notifier.Push do
}
|> Jason.encode!()
end
+
+ defp send_subscription(activity, subscription) do
+ activity
+ |> payload()
+ |> WebPushEncryption.send_web_push(subscription)
+ end
end
diff --git a/lib/service/workers/activity_builder.ex b/lib/service/workers/activity_builder.ex
index cf8a4d1a3..85e4f2deb 100644
--- a/lib/service/workers/activity_builder.ex
+++ b/lib/service/workers/activity_builder.ex
@@ -29,7 +29,7 @@ defmodule Mobilizon.Service.Workers.ActivityBuilder do
def notify_activity(%Activity{} = activity) do
activity
|> users_to_notify()
- |> Enum.each(&Notifier.notify(&1, activity))
+ |> Enum.each(&Notifier.notify(&1, activity, single_activity: true))
end
@spec users_to_notify(Activity.t()) :: list(User.t())
@@ -45,6 +45,6 @@ defmodule Mobilizon.Service.Workers.ActivityBuilder do
|> Enum.map(& &1.user_id)
|> Enum.filter(& &1)
|> Enum.uniq()
- |> Enum.map(&Users.get_user!/1)
+ |> Enum.map(&Users.get_user_with_settings!/1)
end
end
diff --git a/lib/web/email/activity.ex b/lib/web/email/activity.ex
index 01ed0c022..fecdc0d57 100644
--- a/lib/web/email/activity.ex
+++ b/lib/web/email/activity.ex
@@ -17,8 +17,10 @@ defmodule Mobilizon.Web.Email.Activity do
def direct_activity(
email,
activities,
- locale \\ "en"
+ options \\ []
) do
+ locale = Keyword.get(options, :locale, "en")
+ single_activity = Keyword.get(options, :single_activity, false)
Gettext.put_locale(locale)
subject =
@@ -34,6 +36,7 @@ defmodule Mobilizon.Web.Email.Activity do
|> assign(:subject, subject)
|> assign(:activities, chunked_activities)
|> assign(:total_number_activities, length(activities))
+ |> assign(:single_activity, single_activity)
|> render(:email_direct_activity)
end
diff --git a/lib/web/templates/email/email_direct_activity.html.eex b/lib/web/templates/email/email_direct_activity.html.eex
index 149287484..109cd37e8 100644
--- a/lib/web/templates/email/email_direct_activity.html.eex
+++ b/lib/web/templates/email/email_direct_activity.html.eex
@@ -105,7 +105,9 @@
<%= render("activity/_comment_activity_item.html", activity: activity) %>
<% end %>
+ <%= unless @single_activity do %>
<%= datetime_relative(activity.inserted_at, @locale) %>
+ <% end %>
<% end %>
diff --git a/lib/web/templates/email/email_direct_activity.text.eex b/lib/web/templates/email/email_direct_activity.text.eex
index e52c5ed22..4b8a65809 100644
--- a/lib/web/templates/email/email_direct_activity.text.eex
+++ b/lib/web/templates/email/email_direct_activity.text.eex
@@ -11,7 +11,7 @@
<%= for activity <- Enum.take(group_activities, 5) do %>
* <%= case activity.type do %><% :discussion -> %><%= render("activity/_discussion_activity_item.text", activity: activity) %><% :event -> %><%= render("activity/_event_activity_item.text", activity: activity) %><% :group -> %><%= render("activity/_group_activity_item.text", activity: activity) %>
<% :member -> %><%= render("activity/_member_activity_item.text", activity: activity) %><% :post -> %><%= render("activity/_post_activity_item.text", activity: activity) %><% :resource -> %><%= render("activity/_resource_activity_item.text", activity: activity) %><% :comment -> %><%= render("activity/_comment_activity_item.text", activity: activity) %><% end %>
-<%= datetime_relative(activity.inserted_at, @locale) %>
+<%= unless @single_activity do %><%= datetime_relative(activity.inserted_at, @locale) %><% end %>
<% end %>
<%= if length(group_activities) > 5 do %>
<%= dngettext "activity", "View one more activity", "View %{count} more activities", length(group_activities) - 5, %{count: length(group_activities) - 5} %>
diff --git a/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs b/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs
new file mode 100644
index 000000000..bd22dc9e2
--- /dev/null
+++ b/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs
@@ -0,0 +1,10 @@
+defmodule Mobilizon.Storage.Repo.Migrations.AddGroupNotificationAndLastNotificationDateSettings do
+ use Ecto.Migration
+
+ def change do
+ alter table(:user_settings) do
+ add(:group_notifications, :integer, default: 10, nullable: false)
+ add(:last_notification_sent, :utc_datetime, nullable: true)
+ end
+ end
+end
diff --git a/priv/repo/migrations/20210506080303_create_user_push_subscriptions.exs b/priv/repo/migrations/20210506080303_create_user_push_subscriptions.exs
new file mode 100644
index 000000000..6f790981c
--- /dev/null
+++ b/priv/repo/migrations/20210506080303_create_user_push_subscriptions.exs
@@ -0,0 +1,16 @@
+defmodule Mobilizon.Repo.Migrations.CreateUserPushSubscriptions do
+ use Ecto.Migration
+
+ def change do
+ create table(:user_push_subscriptions, primary_key: false) do
+ add(:id, :uuid, primary_key: true)
+ add(:user_id, references(:users, on_delete: :nothing), null: false)
+ add(:digest, :text, null: false)
+ add(:data, :map, null: false)
+
+ timestamps()
+ end
+
+ create(unique_index(:user_push_subscriptions, [:user_id, :digest]))
+ end
+end