From 5d356ea5f29bf376c3870c7df383ac024818a033 Mon Sep 17 00:00:00 2001 From: Mayel de Borniol Date: Mon, 16 Sep 2024 21:08:18 +0100 Subject: [PATCH] WIP https://github.com/bonfire-networks/bonfire-app/issues/1027 --- lib/acts/activity_act.ex | 8 +- lib/acts/live_push_act.ex | 7 +- lib/boosts.ex | 2 +- lib/feed_activities.ex | 8 +- lib/feeds.ex | 93 +++-- lib/live_push.ex | 319 ++++++++++++++++++ .../emails/notification_emails_test.exs | 53 +++ 7 files changed, 451 insertions(+), 39 deletions(-) create mode 100644 lib/live_push.ex create mode 100644 test/social/emails/notification_emails_test.exs diff --git a/lib/acts/activity_act.ex b/lib/acts/activity_act.ex index 69a7d76..06c9c9a 100644 --- a/lib/acts/activity_act.ex +++ b/lib/acts/activity_act.ex @@ -71,8 +71,8 @@ defmodule Bonfire.Social.Acts.Activity do attrs = Keyword.get(epic.assigns[:options], attrs_key, %{}) - notifications_feeds = - Feeds.reply_and_or_mentions_notifications_feeds( + notify = + Feeds.reply_and_or_mentions_to_notify( current_user, boundary_name, e(changeset.changes, :post_content, :changes, :mentions, []), @@ -86,7 +86,7 @@ defmodule Bonfire.Social.Acts.Activity do current_user, boundary_name, epic.assigns, - notifications_feeds + notify[:notify_feeds] ) # feed_ids = Feeds.target_feeds(changeset, current_user, boundary) # duplicate of `Feeds.feed_ids_to_publish` @@ -100,7 +100,7 @@ defmodule Bonfire.Social.Acts.Activity do ) |> Epic.assign(epic, on, ...) |> Epic.assign(..., feeds_key, feed_ids) - |> Epic.assign(..., notify_feeds_key, notifications_feeds) + |> Epic.assign(..., notify_feeds_key, notify) changeset.action == :delete -> # TODO: deletion diff --git a/lib/acts/live_push_act.ex b/lib/acts/live_push_act.ex index fe20265..79cbc25 100644 --- a/lib/acts/live_push_act.ex +++ b/lib/acts/live_push_act.ex @@ -32,7 +32,6 @@ defmodule Bonfire.Social.Acts.LivePush do notify_feeds_key = Keyword.get(act.options, :notify_feeds, :notify_feeds) feeds = Map.get(epic.assigns, feeds_key, []) - notify_feeds = Map.get(epic.assigns, notify_feeds_key, []) maybe_debug( epic, @@ -41,11 +40,11 @@ defmodule Bonfire.Social.Acts.LivePush do "Publishing to feeds at assign #{feeds_key}" ) - maybe_apply(Bonfire.UI.Social.LivePush, :push_activity, [ + Bonfire.Social.LivePush.push_activity( feeds, activity, - [notify: notify_feeds] - ]) + notify: Map.get(epic.assigns, notify_feeds_key, []) + ) |> debug("pushed") |> Epic.assign(epic, on, ...) end diff --git a/lib/boosts.ex b/lib/boosts.ex index 6dea17e..0187788 100644 --- a/lib/boosts.ex +++ b/lib/boosts.ex @@ -192,7 +192,7 @@ defmodule Bonfire.Social.Boosts do # livepush will need a list of feed IDs we published to feed_ids = for fp <- boost.feed_publishes, do: fp.feed_id - maybe_apply(Bonfire.UI.Social.LivePush, :push_activity_object, [ + maybe_apply(Bonfire.Social.LivePush, :push_activity_object, [ feed_ids, boost, boosted, diff --git a/lib/feed_activities.ex b/lib/feed_activities.ex index eb5d0c6..2d96c5d 100644 --- a/lib/feed_activities.ex +++ b/lib/feed_activities.ex @@ -1408,7 +1408,7 @@ defmodule Bonfire.Social.FeedActivities do # # debug(feed_ids) # # |> debug("notify_to_feed_ids") # ret = publish(subject, verb_or_activity, object, to_feeds: feed_ids) - # maybe_apply(Bonfire.UI.Social.LivePush, :notify, [ + # maybe_apply(Bonfire.Social.LivePush, :notify, [ # subject, verb_or_activity, object, feed_ids # ]) # ret @@ -1583,7 +1583,7 @@ defmodule Bonfire.Social.FeedActivities do # |> Circles.circle_ids() |> Enum.map(fn x -> put_in_feeds(x, id(activity), false) end) - if push?, do: maybe_apply(Bonfire.UI.Social.LivePush, :push_activity, [feeds, activity]) + if push?, do: maybe_apply(Bonfire.Social.LivePush, :push_activity, [feeds, activity]) end defp put_in_feeds(feed_or_subject, activity, push?) @@ -1592,7 +1592,7 @@ defmodule Bonfire.Social.FeedActivities do with feed_id <- uid(feed_or_subject), {:ok, _published} <- do_put_in_feeds(feed_id, uid(activity)) do # push to feeds of online users - if push?, do: maybe_apply(Bonfire.UI.Social.LivePush, :push_activity, [feed_id, activity]) + if push?, do: maybe_apply(Bonfire.Social.LivePush, :push_activity, [feed_id, activity]) else e -> error( @@ -1665,7 +1665,7 @@ defmodule Bonfire.Social.FeedActivities do defp hide_activities(fp) when is_list(fp) do for %{id: activity, feed_id: feed_id} <- fp do - maybe_apply(Bonfire.UI.Social.LivePush, :hide_activity, [ + maybe_apply(Bonfire.Social.LivePush, :hide_activity, [ feed_id, activity ]) diff --git a/lib/feeds.ex b/lib/feeds.ex index 7e2bbb1..30da0ac 100644 --- a/lib/feeds.ex +++ b/lib/feeds.ex @@ -46,19 +46,19 @@ defmodule Bonfire.Social.Feeds do > Bonfire.Social.Feeds.feed_ids_to_publish(me, "public", %{reply_to: true}, [some_feed_id]) # List of feed IDs for the provided boundary """ - def feed_ids_to_publish(me, boundary, assigns, reply_and_or_mentions_notifications_feeds \\ nil) + def feed_ids_to_publish(me, boundary, assigns, notify_feeds \\ nil) def feed_ids_to_publish(_me, "admins", _, _) do admins_notifications() |> debug("posting to admin feeds") end - def feed_ids_to_publish(me, boundary, assigns, reply_and_or_mentions_notifications_feeds) do + def feed_ids_to_publish(me, boundary, assigns, notify_feeds) do [ e(assigns, :reply_to, :replied, :thread, :id, nil), maybe_my_outbox_feed_id(me, boundary), global_feed_ids(boundary), - reply_and_or_mentions_notifications_feeds || + notify_feeds || reply_and_or_mentions_notifications_feeds( me, boundary, @@ -129,53 +129,94 @@ defmodule Bonfire.Social.Feeds do reply_to_creator, to_circles \\ [] ) do - my_notifications = feed_id(:notifications, me) - - (user_notifications_feeds([reply_to_creator], boundary) ++ - user_notifications_feeds( - mentions, - boundary, - to_circles - )) - |> Enums.filter_empty([]) - |> Enum.uniq() + # my_notifications = feed_id(:notifications, me) + + filter_reply_and_or_mentions(me, reply_to_creator, mentions) + |> users_to_notify( + boundary, + to_circles + ) + |> notify_feeds() + # avoid self-notifying - |> Enum.reject(&(&1 == my_notifications)) + # |> Enum.reject(&(&1 == my_notifications)) + # |> debug() + end + + def reply_and_or_mentions_to_notify( + me, + boundary, + mentions, + reply_to_creator, + to_circles \\ [] + ) do + users = + filter_reply_and_or_mentions(me, reply_to_creator, mentions) + |> debug() + |> users_to_notify( + boundary, + to_circles + ) + + %{ + notify_feeds: notify_feeds(users), + notify_emails: notify_emails(users) + } |> debug() end - defp user_notifications_feeds(users, boundary, to_circles \\ []) do + defp filter_reply_and_or_mentions(me, reply_to_creator, mentions) do + my_id = Enums.id(me) + + ([reply_to_creator] ++ mentions) + # avoid self-notifying + |> Enum.reject(&(Enums.id(&1) == my_id)) + end + + defp users_to_notify(users, boundary, to_circles \\ []) do # debug(epic, act, users, "users going in") cond do boundary in ["public", "mentions"] -> users - |> debug() |> filter_empty([]) - |> repo().maybe_preload([:character]) - |> Enum.map(&feed_id(:notifications, &1)) + |> repo().maybe_preload([:character, :settings]) boundary == "local" -> users - |> debug() |> filter_empty([]) - |> repo().maybe_preload([:character, :peered]) - # only local + |> repo().maybe_preload([:character, :peered, :settings]) + # notify only local users |> Enum.filter(&is_local?/1) - |> Enum.map(&feed_id(:notifications, &1)) true -> # we should only notify mentions & reply_to_creator IF they are included in the object's boundaries + # TODO: check also if they can read the object otherwise (for example, by being member of an included circle) to_circles_ids = Enums.ids(to_circles) users |> filter_empty([]) |> Enum.filter(&(id(&1) in to_circles_ids)) - |> repo().maybe_preload([:character]) - |> debug() - # only local - |> Enum.map(&feed_id(:notifications, &1)) + |> repo().maybe_preload([:character, :settings]) end + |> debug() + end + + defp notify_feeds(users) do + users + |> Enum.map(&feed_id(:notifications, &1)) + |> Enums.filter_empty([]) + |> Enum.uniq() + end + + defp notify_emails(users) do + users + |> Enum.filter(&Settings.get([:email_notifications, :reply_or_mentions], false, &1)) + |> repo().maybe_preload(accounted: [account: [:email]]) + |> debug() + |> Enum.map(&e(&1, :accounted, :account, :email, :email_address, nil)) + |> Enums.filter_empty([]) + |> Enum.uniq() end @doc """ diff --git a/lib/live_push.ex b/lib/live_push.ex new file mode 100644 index 0000000..d97b1c2 --- /dev/null +++ b/lib/live_push.ex @@ -0,0 +1,319 @@ +defmodule Bonfire.Social.LivePush do + @moduledoc "Handles pushing activities (via PubSub and/or email) to active feeds and notifications" + + use Bonfire.Common.Utils + import Untangle + import Bonfire.Social + alias Bonfire.Common.PubSub + alias Bonfire.Social.Activities + alias Bonfire.Social.FeedActivities + alias Bonfire.Data.Social.Activity + + @doc """ + Receives an activity with a nested object, or vice versa, uses PubSub to pushes to feeds and optionally notifications + """ + def push_activity(to_feeds, activity, opts \\ []) + + def push_activity(to_feeds, %Activity{} = activity, opts) do + debug(to_feeds, "push a :new_activity to feed_ids") + activity = prepare_activity(activity, opts) + + has_feed_ids? = is_binary(to_feeds) or (is_list(to_feeds) and to_feeds != []) + + if has_feed_ids?, + do: + PubSub.broadcast(to_feeds, { + {Bonfire.Social.Feeds, :new_activity}, + [ + feed_ids: to_feeds, + activity: activity + ] + }) + + if Keyword.get(opts, :push_to_thread, true), do: maybe_push_thread(activity) + + notify(activity, Keyword.put(opts, :feed_ids, to_feeds)) + + activity + end + + def push_activity( + to_notify, + %{id: _, activity: _activity} = object, + opts + ) do + debug(to_notify, "push an object as :new_activity") + + activity_from_object(object) + |> push_activity(to_notify, ..., opts) + # returns the object + the preloaded activity + |> Map.put(object, :activity, ...) + end + + def push_activity(_to_notify, activity, _opts) do + warn(activity, "skip invalid activity") + activity + end + + @doc """ + Receives an activity *and* object, uses PubSub to pushes to feeds and optionally notifications, and returns an Activity. + """ + def push_activity_object( + to_notify, + %{id: _, activity: %{id: _}} = parent_object, + object, + opts + ) do + debug(to_notify, "push an activity with custom object as :new_activity") + + # add object assocs to the activity + maybe_merge_to_struct( + parent_object.activity, + Map.drop(parent_object, [:activity]) + ) + # push as activity with :object + |> Map.put(:object, Map.drop(object, [:activity])) + |> Map.drop([:activity]) + |> push_activity(to_notify, ..., opts) + end + + def hide_activity(feed_id, activity_id) do + PubSub.broadcast(feed_id, { + {Bonfire.Social.Feeds, :hide_activity}, + activity_id + }) + + # also send to the thread + # TODO: only do this for thread roots, and otherwise notify the actual thread + PubSub.broadcast(activity_id, { + {Bonfire.Social.Feeds, :hide_activity}, + activity_id + }) + + # TODO! + # if Keyword.get(opts, :push_to_thread, true), do: maybe_push_thread(activity) + end + + def notify_of_message(subject, verb, object, users) do + activity_from_object(object) + |> prepare_activity() + |> maybe_push_thread() + + subject_id = uid(subject) + + users = + users + |> Enum.reject(&(uid(&1) == subject_id)) + |> debug() + + # FIXME: avoid querying this again + FeedActivities.get_feed_ids(inbox: users) + |> increment_counters(:inbox) + + notify_users(subject, verb, object, users) + end + + @doc """ + Sends a notification about an activity to a list of users, excluding the author/subject + """ + def notify_users(subject, verb, object, users) do + subject_id = uid(subject) + + # TODO: send email notif + + users + |> Enum.reject(&(uid(&1) == subject_id)) + |> FeedActivities.get_feed_ids(notifications: ...) + |> normalise_feed_ids() + |> send_notifications(subject, verb, object, ...) + end + + def prepare_activity(%Activity{} = activity, opts \\ []) do + Activities.activity_preloads(activity, [:feed_metadata, :feed_postload], opts) + + # |> debug("make sure that all needed assocs are preloaded without n+1") + end + + def notify(activity, opts), + do: notify(activity.subject, activity.verb, activity.object, opts) + + def notify(subject, verb, object, opts) do + send_notifications(subject, verb, object, opts) + end + + defp send_notifications(subject, verb, object, opts \\ []) do + verb_display = + Bonfire.Social.Activities.verb_name(verb) + |> Bonfire.Social.Activities.verb_display() + + avatar = Media.avatar_url(subject) + + icon = + cond do + is_binary(avatar) and avatar != Media.avatar_fallback() -> avatar + true -> Config.get([:ui, :theme, :instance_icon], "/images/bonfire-icon.png") + end + + {feed_ids, notify_emails} = + case (Keyword.keyword?(opts) && Keyword.get(opts, :notify)) || opts do + %{notify_feeds: notify_feeds, notify_emails: notify_emails} -> + {notify_feeds, notify_emails} + + %{notify_emails: notify_emails} -> + {[], notify_emails} + + %{notify_feeds: notify_feeds} -> + {notify_feeds, []} + + notify_feeds when is_list(notify_feeds) and notify_feeds != [] -> + {notify_feeds, []} + + true -> + {Keyword.get(opts, :feed_ids, []), []} + + _ -> + {[], []} + end + + feed_ids = normalise_feed_ids(feed_ids) + + # increment currently visible unread counters + increment_counters(feed_ids, :notifications) + + content = + e( + object, + :post_content, + :name, + nil + ) || + e( + object, + :named, + :name, + nil + ) || + e( + object, + :name, + nil + ) || + e( + object, + :post_content, + :summary, + nil + ) || + Text.maybe_markdown_to_html( + e( + object, + :post_content, + :html_body, + nil + ) + ) || e(object, :profile, :name, nil) || + e(object, :character, :username, nil) + + preview_assigns = %{ + title: + (e(subject, :profile, :name, nil) || e(subject, :character, :username, "")) <> + " #{verb_display}", + message: Text.text_only(content || ""), + url: path(object), + icon: icon || Config.get([:ui, :theme, :instance_icon], nil) + } + + # TODO: send email notif? + warn(notify_emails, "TODO") + + if is_list(notify_emails) and notify_emails != [] do + url = URIs.based_url(preview_assigns[:url]) + + email = + Bonfire.Mailer.new( + subject: "[Bonfire] " <> preview_assigns[:title], + html_body: + preview_assigns[:title] <> + "

#{content}

See details", + text_body: + preview_assigns[:title] <> "\n\n" <> preview_assigns[:message] <> "\n\n" <> url + ) + |> debug() + + Enum.map(notify_emails, &(Bonfire.Mailer.send_now(email, &1) |> debug())) + end + + maybe_apply(Bonfire.UI.Common.Notifications, :notify_broadcast, [feed_ids, preview_assigns]) + end + + defp increment_counters(feed_ids, box) do + feed_ids + |> Enum.map(&"unseen_count:#{box}:#{&1}") + |> PubSub.broadcast({{Bonfire.Social.Feeds, :count_increment}, box}) + end + + defp normalise_feed_ids(feed_ids) do + feed_ids + # |> debug("input") + |> uids() + + # |> debug("normalised") + end + + defp activity_from_object(%{id: _, activity: _activity} = object) do + # TODO: optimise and put elsewhere + object = + object + |> repo().maybe_preload(:activity) + + activity = + object + |> Map.get(:activity) + + object = + object + |> Map.drop([:activity]) + + # add object assocs to the activity + maybe_merge_to_struct(activity, object) + # push as activity with :object + |> Map.put(:object, object) + |> Map.drop([:activity]) + end + + defp maybe_push_thread(%{replied: %{id: _} = replied} = activity) do + maybe_push_thread(replied, activity) + end + + defp maybe_push_thread(%{object: %{replied: %{id: _} = replied} = _object} = activity) do + maybe_push_thread(replied, activity) + end + + defp maybe_push_thread(activity) do + debug(activity, "no replied info found}") + nil + end + + defp maybe_push_thread( + %{thread_id: thread_id, reply_to_id: _reply_to_id}, + activity + ) + when is_binary(thread_id) do + debug( + thread_id, + "broadcasting to anyone currently viewing the thread" + ) + + PubSub.broadcast( + thread_id, + {{Bonfire.Social.Threads.LiveHandler, :new_reply}, {thread_id, activity}} + ) + + # PubSub.broadcast(reply_to_id, {{Bonfire.Social.Threads.LiveHandler, :new_reply}, {reply_to_id, activity}}) + end + + defp maybe_push_thread(replied, _activity) do + debug(replied, "maybe_push_thread: no reply_to info found}") + nil + end +end diff --git a/test/social/emails/notification_emails_test.exs b/test/social/emails/notification_emails_test.exs new file mode 100644 index 0000000..df05dac --- /dev/null +++ b/test/social/emails/notification_emails_test.exs @@ -0,0 +1,53 @@ +defmodule Bonfire.Social.NotificationEmailsTest do + # Use the module + use Bonfire.Social.ConnCase, async: true + + import Swoosh.TestAssertions + alias Bonfire.Posts + + setup do + Process.put([:bonfire_mailer, Bonfire.Mailer, :mailer_behaviour], Bonfire.Mailer.Swoosh) + on_exit(fn -> Process.delete([:bonfire_mailer, Bonfire.Mailer, :mailer_behaviour]) end) + end + + test "standard integration works" do + account = fake_account!() + alice = fake_user!(account) + + account2 = fake_account!() + bob = fake_user!(account2) + + account3 = fake_account!() + carl = fake_user!(account3) + + # Follows.follow(alice, bob) + + attrs = %{ + post_content: %{summary: "summary", name: "test post name", html_body: "

first post

"} + } + + assert {:ok, post} = Posts.publish(current_user: alice, post_attrs: attrs, boundary: "public") + + # Reply to the original post + attrs_reply = %{ + post_content: %{ + summary: "summary", + name: "name 2", + html_body: "reply to first post @#{carl.character.username}" + }, + reply_to_id: post.id + } + + assert {:ok, post_reply} = + Posts.publish(current_user: bob, post_attrs: attrs_reply, boundary: "public") + + # assert an email with specific field(s) was sent + # assert_email_sent(subject: "test - bonfire Hello, Avengers!") + + # assert an email that satisfies a condition + assert_email_sent(fn email -> + assert length(email.to) == 1 + assert email.text_body =~ bob.profile.name + end) + end +end