diff --git a/assets/css/common/_base.scss b/assets/css/common/_base.scss index 7aa8ec97..0b3452dd 100644 --- a/assets/css/common/_base.scss +++ b/assets/css/common/_base.scss @@ -480,6 +480,7 @@ span.stat { @import "views/filters"; @import "views/galleries"; @import "views/images"; +@import "views/notifications"; @import "views/pages"; @import "views/polls"; @import "views/posts"; diff --git a/assets/css/views/_notifications.scss b/assets/css/views/_notifications.scss new file mode 100644 index 00000000..8c4f327e --- /dev/null +++ b/assets/css/views/_notifications.scss @@ -0,0 +1,11 @@ +.notification-type-block:not(:last-child) { + margin-bottom: 20px; +} + +.notification { + margin-bottom: 0; +} + +.notification:not(:last-child) { + border-bottom: 0; +} diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index a82094b3..cbff31ee 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -6,19 +6,82 @@ defmodule Philomena.Notifications do import Ecto.Query, warn: false alias Philomena.Repo + alias Philomena.Notifications.Category alias Philomena.Notifications.Notification + alias Philomena.Notifications.UnreadNotification + alias Philomena.Polymorphic @doc """ - Returns the list of notifications. + Returns the list of unread notifications of the given type. + + The set of valid types is `t:Philomena.Notifications.Category.t/0`. ## Examples - iex> list_notifications() + iex> unread_notifications_for_user_and_type(user, :image_comment, ...) [%Notification{}, ...] """ - def list_notifications do - Repo.all(Notification) + def unread_notifications_for_user_and_type(user, type, pagination) do + notifications = + user + |> unread_query_for_type(type) + |> Repo.paginate(pagination) + + put_in(notifications.entries, load_associations(notifications.entries)) + end + + @doc """ + Gather up and return the top N notifications for the user, for each type of + unread notification currently existing. + + ## Examples + + iex> unread_notifications_for_user(user) + [ + forum_topic: [%Notification{...}, ...], + forum_post: [%Notification{...}, ...], + image_comment: [%Notification{...}, ...] + ] + + """ + def unread_notifications_for_user(user, n) do + Category.types() + |> Enum.map(fn type -> + q = + user + |> unread_query_for_type(type) + |> limit(^n) + + # Use a subquery to ensure the order by is applied to the + # subquery results only, and not the main query results + from(n in subquery(q)) + end) + |> union_all_queries() + |> Repo.all() + |> load_associations() + |> Enum.group_by(&Category.notification_type/1) + |> Enum.sort_by(fn {k, _v} -> k end) + end + + defp unread_query_for_type(user, type) do + from n in Category.query_for_type(type), + join: un in UnreadNotification, + on: un.notification_id == n.id, + where: un.user_id == ^user.id, + order_by: [desc: :updated_at] + end + + defp union_all_queries([query | rest]) do + Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end) + end + + defp load_associations(notifications) do + Polymorphic.load_polymorphic( + notifications, + actor: [actor_id: :actor_type], + actor_child: [actor_child_id: :actor_child_type] + ) end @doc """ @@ -102,8 +165,6 @@ defmodule Philomena.Notifications do Notification.changeset(notification, %{}) end - alias Philomena.Notifications.UnreadNotification - def count_unread_notifications(user) do UnreadNotification |> where(user_id: ^user.id) diff --git a/lib/philomena/notifications/category.ex b/lib/philomena/notifications/category.ex new file mode 100644 index 00000000..775b888d --- /dev/null +++ b/lib/philomena/notifications/category.ex @@ -0,0 +1,93 @@ +defmodule Philomena.Notifications.Category do + @moduledoc """ + Notification category determination. + """ + + import Ecto.Query, warn: false + alias Philomena.Notifications.Notification + + @type t :: + :channel_live + | :forum_post + | :forum_topic + | :gallery_image + | :image_comment + | :image_merge + + @doc """ + Return a list of all supported types. + """ + def types do + [ + :channel_live, + :forum_topic, + :gallery_image, + :image_comment, + :image_merge, + :forum_post + ] + end + + @doc """ + Determine the type of a `m:Philomena.Notifications.Notification`. + """ + def notification_type(n) do + case {n.actor_type, n.actor_child_type} do + {"Channel", _} -> + :channel_live + + {"Gallery", _} -> + :gallery_image + + {"Image", "Comment"} -> + :image_comment + + {"Image", _} -> + :image_merge + + {"Topic", "Post"} -> + if n.action == "posted a new reply in" do + :forum_post + else + :forum_topic + end + end + end + + @doc """ + Returns an `m:Ecto.Query` that finds notifications for the given type. + """ + def query_for_type(type) do + base = from(n in Notification) + + case type do + :channel_live -> + where(base, [n], n.actor_type == "Channel") + + :gallery_image -> + where(base, [n], n.actor_type == "Gallery") + + :image_comment -> + where(base, [n], n.actor_type == "Image" and n.actor_child_type == "Comment") + + :image_merge -> + where(base, [n], n.actor_type == "Image" and is_nil(n.actor_child_type)) + + :forum_topic -> + where( + base, + [n], + n.actor_type == "Topic" and n.actor_child_type == "Post" and + n.action != "posted a new reply in" + ) + + :forum_post -> + where( + base, + [n], + n.actor_type == "Topic" and n.actor_child_type == "Post" and + n.action == "posted a new reply in" + ) + end + end +end diff --git a/lib/philomena_web/controllers/notification/category_controller.ex b/lib/philomena_web/controllers/notification/category_controller.ex new file mode 100644 index 00000000..76142581 --- /dev/null +++ b/lib/philomena_web/controllers/notification/category_controller.ex @@ -0,0 +1,33 @@ +defmodule PhilomenaWeb.Notification.CategoryController do + use PhilomenaWeb, :controller + + alias Philomena.Notifications + + def show(conn, params) do + type = category(params) + + notifications = + Notifications.unread_notifications_for_user_and_type( + conn.assigns.current_user, + type, + conn.assigns.scrivener + ) + + render(conn, "show.html", + title: "Notification Area", + notifications: notifications, + type: type + ) + end + + defp category(params) do + case params["id"] do + "channel_live" -> :channel_live + "gallery_image" -> :gallery_image + "image_comment" -> :image_comment + "image_merge" -> :image_merge + "forum_topic" -> :forum_topic + _ -> :forum_post + end + end +end diff --git a/lib/philomena_web/controllers/notification_controller.ex b/lib/philomena_web/controllers/notification_controller.ex index 170d504b..a21f345f 100644 --- a/lib/philomena_web/controllers/notification_controller.ex +++ b/lib/philomena_web/controllers/notification_controller.ex @@ -1,33 +1,10 @@ defmodule PhilomenaWeb.NotificationController do use PhilomenaWeb, :controller - alias Philomena.Notifications.{UnreadNotification, Notification} - alias Philomena.Polymorphic - alias Philomena.Repo - import Ecto.Query + alias Philomena.Notifications def index(conn, _params) do - user = conn.assigns.current_user - - notifications = - from n in Notification, - join: un in UnreadNotification, - on: un.notification_id == n.id, - where: un.user_id == ^user.id - - notifications = - notifications - |> order_by(desc: :updated_at) - |> Repo.paginate(conn.assigns.scrivener) - - entries = - notifications.entries - |> Polymorphic.load_polymorphic( - actor: [actor_id: :actor_type], - actor_child: [actor_child_id: :actor_child_type] - ) - - notifications = %{notifications | entries: entries} + notifications = Notifications.unread_notifications_for_user(conn.assigns.current_user, 15) render(conn, "index.html", title: "Notification Area", notifications: notifications) end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 7a89f4b1..31be1073 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -173,6 +173,7 @@ defmodule PhilomenaWeb.Router do scope "/notifications", Notification, as: :notification do resources "/unread", UnreadController, only: [:index] + resources "/categories", CategoryController, only: [:show] end resources "/notifications", NotificationController, only: [:index, :delete] diff --git a/lib/philomena_web/templates/notification/_notification.html.slime b/lib/philomena_web/templates/notification/_notification.html.slime index 688df590..dfc34b18 100644 --- a/lib/philomena_web/templates/notification/_notification.html.slime +++ b/lib/philomena_web/templates/notification/_notification.html.slime @@ -1,5 +1,5 @@ = if @notification.actor do - .block.block--fixed.flex id="notification-#{@notification.id}" + .block.block--fixed.flex.notification id="notification-#{@notification.id}" = if @notification.actor_type == "Image" and @notification.actor do .flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right = render PhilomenaWeb.ImageView, "_image_container.html", image: @notification.actor, size: :thumb_tiny, conn: @conn diff --git a/lib/philomena_web/templates/notification/category/show.html.slime b/lib/philomena_web/templates/notification/category/show.html.slime new file mode 100644 index 00000000..59f2f9d5 --- /dev/null +++ b/lib/philomena_web/templates/notification/category/show.html.slime @@ -0,0 +1,28 @@ +h1 Notification Area +.walloftext + = cond do + - Enum.any?(@notifications) -> + - route = fn p -> ~p"/notifications/categories/#{@type}?#{p}" end + - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn + + .block.notification-type-block + .block__header + span.block__header__title = name_of_type(@type) + .block__header.block__header__sub + = pagination + + div + = for notification <- @notifications do + = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + + .block__header.block__header--light + = pagination + + - true -> + p You currently have no notifications of this category. + p + ' To get notifications on new comments and forum posts, click the + ' 'Subscribe' button in the bar at the top of an image or forum topic. + + a.button href=~p"/notifications" + ' View all notifications diff --git a/lib/philomena_web/templates/notification/index.html.slime b/lib/philomena_web/templates/notification/index.html.slime index 9cec1086..fa957442 100644 --- a/lib/philomena_web/templates/notification/index.html.slime +++ b/lib/philomena_web/templates/notification/index.html.slime @@ -1,14 +1,19 @@ -- route = fn p -> ~p"/notifications?#{p}" end - h1 Notification Area .walloftext - .block__header - = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn - = cond do - Enum.any?(@notifications) -> - = for notification <- @notifications do - = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + = for {type, notifications} <- @notifications do + .block.notification-type-block + .block__header + span.block__header__title = name_of_type(type) + + div + = for notification <- notifications do + = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn + + .block__header.block__header--light + a href=~p"/notifications/categories/#{type}" + | View category - true -> p diff --git a/lib/philomena_web/views/notification/category_view.ex b/lib/philomena_web/views/notification/category_view.ex new file mode 100644 index 00000000..148d94f5 --- /dev/null +++ b/lib/philomena_web/views/notification/category_view.ex @@ -0,0 +1,5 @@ +defmodule PhilomenaWeb.Notification.CategoryView do + use PhilomenaWeb, :view + + defdelegate name_of_type(type), to: PhilomenaWeb.NotificationView +end diff --git a/lib/philomena_web/views/notification_view.ex b/lib/philomena_web/views/notification_view.ex index 52d05201..dcaf81dd 100644 --- a/lib/philomena_web/views/notification_view.ex +++ b/lib/philomena_web/views/notification_view.ex @@ -13,4 +13,26 @@ defmodule PhilomenaWeb.NotificationView do def notification_template_path(actor_type) do @template_paths[actor_type] end + + def name_of_type(notification_type) do + case notification_type do + :channel_live -> + "Live channels" + + :forum_post -> + "New replies in topics" + + :forum_topic -> + "New topics" + + :gallery_image -> + "Updated galleries" + + :image_comment -> + "New replies on images" + + :image_merge -> + "Image merges" + end + end end