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