Packages

phoenix_kit

1.7.214
1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps โ€” SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live notifications inbox.ex
Raw

lib/phoenix_kit_web/live/notifications/inbox.ex

defmodule PhoenixKitWeb.Live.Notifications.Inbox do
@moduledoc """
"My Notifications" โ€” the current user's personal notification inbox.
Shows the full history of notifications addressed to the logged-in user
(unlike the bell dropdown, which only shows the recent unseen ones), with
per-row mark-seen / dismiss and a mark-all-seen action. Updates live over
the user's PubSub topic, so a notification created elsewhere (another tab,
the bell, another actor) appears without a refresh.
Access is gated by the base `notifications` permission (the admin
`live_session` `on_mount` + `@admin_view_permissions`). The recipient is
ALWAYS the scope's user โ€” never taken from params/URL/PubSub โ€” so one user
can only ever see and act on their own notifications.
"""
use PhoenixKitWeb, :live_view
alias PhoenixKit.Notifications
alias PhoenixKit.Notifications.Events
alias PhoenixKit.Notifications.Render
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Routes
@per_page 25
@impl true
def mount(_params, _session, socket) do
user_uuid = Scope.user_uuid(socket.assigns[:phoenix_kit_current_scope])
if connected?(socket) and is_binary(user_uuid) do
Events.subscribe(user_uuid)
end
{:ok,
socket
|> assign(:page_title, gettext("My Notifications"))
|> assign(:project_title, Settings.get_project_title())
# Notifications are private per person, so the view/filter state lives in
# LiveView assigns, not the URL โ€” nothing here is worth sharing or
# deep-linking. `url_path` is the page's own path (for nav highlighting).
|> assign(:url_path, Routes.path("/admin/notifications"))
|> assign(:user_uuid, user_uuid)
|> assign(:per_page, @per_page)
|> assign(:view, :inbox)
|> assign(:status, :all)
|> assign(:limit, @per_page)
|> load()}
end
@impl true
def handle_event("mark_seen", %{"uuid" => uuid}, socket) do
Notifications.mark_seen(socket.assigns.user_uuid, uuid)
{:noreply, load(socket)}
end
def handle_event("dismiss", %{"uuid" => uuid}, socket) do
Notifications.dismiss(socket.assigns.user_uuid, uuid)
{:noreply, load(socket)}
end
def handle_event("mark_all_seen", _params, socket) do
Notifications.mark_all_seen(socket.assigns.user_uuid)
{:noreply, load(socket)}
end
# "Dismissed" filter toggle: off โ†’ active inbox, on โ†’ the dismissed ("trash")
# list. Reset the unread filter when leaving the inbox (it doesn't apply there).
def handle_event("toggle_dismissed", _params, socket) do
view = if socket.assigns.view == :dismissed, do: :inbox, else: :dismissed
{:noreply, socket |> assign(:view, view) |> assign(:status, :all) |> reset_limit() |> load()}
end
# "Unread" filter toggle: off โ†’ all, on โ†’ unread only.
def handle_event("toggle_unread", _params, socket) do
status = if socket.assigns.status == :unread, do: :all, else: :unread
{:noreply, socket |> assign(:status, status) |> reset_limit() |> load()}
end
# In-memory "load more": grow the fetch window (no URL, no page params).
def handle_event("load_more", _params, socket) do
{:noreply, socket |> assign(:limit, socket.assigns.limit + socket.assigns.per_page) |> load()}
end
# Restore (un-dismiss) a notification โ€” the soft-delete "restore" action.
def handle_event("restore", %{"uuid" => uuid}, socket) do
Notifications.restore(socket.assigns.user_uuid, uuid)
{:noreply, load(socket)}
end
# Clicking a notification's title marks it seen and navigates to its resource
# link (mirrors the bell). A link-less notification just clears its unread
# state and stays on the page.
def handle_event("open_notification", %{"uuid" => uuid}, socket) do
case Notifications.mark_seen(socket.assigns.user_uuid, uuid) do
{:ok, notification} ->
case Render.render(notification, socket.assigns[:current_locale]).link do
nil -> {:noreply, load(socket)}
link -> {:noreply, push_navigate(socket, to: link)}
end
_ ->
{:noreply, load(socket)}
end
end
# Live updates: any change to this user's notifications (own action, another
# tab, the bell, or a new one arriving) reloads the current page.
@impl true
def handle_info({event, _payload}, socket)
when event in [
:notification_created,
:notification_seen,
:notification_dismissed,
:notifications_bulk_updated
] do
{:noreply, load(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
# โ”€โ”€ Internals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Single read per refresh (list + unread count), no N+1 โ€” list_for_user/2
# already preloads activity + actor.
# Fetch the first `limit` rows (grows via "load more") โ€” no offset paging, so
# a live update or filter change always re-reads a coherent window from the top.
defp load(%{assigns: %{user_uuid: user_uuid}} = socket) when is_binary(user_uuid) do
dismissed = if socket.assigns.view == :dismissed, do: :only, else: :exclude
# The unread filter only applies to the active inbox.
status = if socket.assigns.view == :dismissed, do: :all, else: socket.assigns.status
{rows, total} =
Notifications.list_for_user(user_uuid,
page: 1,
per_page: socket.assigns.limit,
status: status,
dismissed: dismissed
)
socket
|> assign(:notifications, rows)
|> assign(:total, total)
|> assign(:unread_count, Notifications.count_unread(user_uuid))
end
defp load(socket) do
socket
|> assign(:notifications, [])
|> assign(:total, 0)
|> assign(:unread_count, 0)
end
defp reset_limit(socket), do: assign(socket, :limit, socket.assigns.per_page)
defp relative_time(%DateTime{} = dt) do
seconds = DateTime.diff(DateTime.utc_now(), dt)
cond do
seconds < 60 -> gettext("just now")
seconds < 3600 -> gettext("%{n}m ago", n: div(seconds, 60))
seconds < 86_400 -> gettext("%{n}h ago", n: div(seconds, 3600))
seconds < 604_800 -> gettext("%{n}d ago", n: div(seconds, 86_400))
true -> Calendar.strftime(dt, "%b %d")
end
end
defp relative_time(_), do: ""
# Group the (already newest-first) rows into contiguous day buckets so a long
# history stays scannable. Rows are desc-sorted by inserted_at, so buckets are
# contiguous โ€” chunk_by preserves order without a re-sort.
defp grouped(notifications) do
today = Date.utc_today()
notifications
|> Enum.chunk_by(&bucket_key(&1, today))
|> Enum.map(fn [first | _] = chunk -> {bucket_label(bucket_key(first, today)), chunk} end)
end
defp bucket_key(%{inserted_at: %DateTime{} = dt}, today) do
case Date.diff(today, DateTime.to_date(dt)) do
d when d <= 0 -> :today
1 -> :yesterday
d when d <= 6 -> :this_week
_ -> :earlier
end
end
defp bucket_key(_, _), do: :earlier
defp bucket_label(:today), do: gettext("Today")
defp bucket_label(:yesterday), do: gettext("Yesterday")
defp bucket_label(:this_week), do: gettext("Earlier this week")
defp bucket_label(:earlier), do: gettext("Earlier")
# View/status-aware empty-state copy.
defp empty_title(:dismissed, _), do: gettext("No dismissed notifications")
defp empty_title(_, :unread), do: gettext("No unread notifications")
defp empty_title(_, _), do: gettext("You're all caught up")
defp empty_subtitle(:dismissed, _), do: gettext("Notifications you dismiss will appear here.")
defp empty_subtitle(_, :unread), do: gettext("You've read everything here.")
defp empty_subtitle(_, _), do: gettext("Notifications you receive will show up here.")
@impl true
def render(assigns) do
~H"""
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
socket={@socket}
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={gettext("My Notifications")}
page_subtitle={gettext("Notifications addressed to you")}
current_path={@url_path}
project_title={@project_title}
current_locale={assigns[:current_locale]}
>
<div class="p-6 max-w-2xl mx-auto">
<%!-- Toolbar: mark-all-read (left) ยท filter chips (right). We're already
in the inbox, so the chips are on/off filters, not navigation tabs.
Off = ghost/outlined pill, on = filled. The Unread filter doesn't
apply to the dismissed ("trash") list, so it stays visible but
disabled there rather than vanishing. --%>
<div class="flex flex-wrap items-center gap-2 mb-5">
<%= if @view == :inbox and @unread_count > 0 do %>
<button type="button" phx-click="mark_all_seen" class="btn btn-ghost btn-sm gap-1.5">
<.icon name="hero-check" class="w-4 h-4" /> {gettext("Mark all read")}
</button>
<% end %>
<div class="flex items-center gap-2 ml-auto">
<button
type="button"
phx-click="toggle_unread"
disabled={@view == :dismissed}
aria-pressed={@status == :unread}
class={[
"btn btn-sm rounded-full gap-1.5",
if(@status == :unread, do: "btn-primary", else: "btn-ghost border border-base-300")
]}
>
{gettext("Unread")}
<%= if @unread_count > 0 do %>
<span class={["badge badge-xs", if(@status == :unread, do: "badge-ghost")]}>
{@unread_count}
</span>
<% end %>
</button>
<button
type="button"
phx-click="toggle_dismissed"
aria-pressed={@view == :dismissed}
class={[
"btn btn-sm rounded-full gap-1.5",
if(@view == :dismissed, do: "btn-primary", else: "btn-ghost border border-base-300")
]}
>
<.icon name="hero-trash" class="w-4 h-4" />
{gettext("Dismissed")}
</button>
</div>
</div>
<%= if @notifications == [] do %>
<div class="text-center py-20">
<div class="w-14 h-14 rounded-full bg-base-200 grid place-items-center mx-auto mb-4">
<.icon name="hero-bell-slash" class="w-6 h-6 text-base-content/40" />
</div>
<p class="font-medium">{empty_title(@view, @status)}</p>
<p class="text-sm text-base-content/50 mt-1">{empty_subtitle(@view, @status)}</p>
</div>
<% else %>
<div class="space-y-6">
<%= for {label, group} <- grouped(@notifications) do %>
<section>
<h2 class="text-xs font-semibold uppercase tracking-wide text-base-content/40 mb-2 px-1">
{label}
</h2>
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
<ul class="divide-y divide-base-200 m-0 p-0">
<%= for n <- group do %>
<% view = Render.render(n, assigns[:current_locale]) %>
<li class={[
"group/row flex items-start gap-3 px-4 py-3.5 transition-colors hover:bg-base-200/40",
is_nil(n.seen_at) && "bg-primary/[0.04]"
]}>
<%!-- Type icon in a chip, tinted while unread --%>
<div class={[
"shrink-0 w-9 h-9 rounded-full grid place-items-center",
if(is_nil(n.seen_at),
do: "bg-primary/15 text-primary",
else: "bg-base-200 text-base-content/50"
)
]}>
<.icon name={view.icon} class="w-5 h-5" />
</div>
<div class="flex-1 min-w-0 pt-0.5">
<button
type="button"
phx-click="open_notification"
phx-value-uuid={n.uuid}
class={[
"text-sm leading-snug text-left hover:underline focus:underline outline-none cursor-pointer",
is_nil(n.seen_at) && "font-semibold"
]}
>
{view.text}
</button>
<p class="text-xs text-base-content/50 mt-1">
{relative_time(n.inserted_at)}
</p>
</div>
<%!-- Actions: subtle by default, full on row hover/focus.
Stays visible enough to tap on touch. --%>
<div class="shrink-0 flex items-center gap-0.5 opacity-60 group-hover/row:opacity-100 focus-within:opacity-100 transition-opacity">
<%= if @view == :dismissed do %>
<button
type="button"
phx-click="restore"
phx-value-uuid={n.uuid}
class="btn btn-ghost btn-xs btn-square"
title={gettext("Restore")}
>
<.icon name="hero-arrow-uturn-left" class="w-4 h-4" />
</button>
<% else %>
<%= if is_nil(n.seen_at) do %>
<button
type="button"
phx-click="mark_seen"
phx-value-uuid={n.uuid}
class="btn btn-ghost btn-xs btn-square"
title={gettext("Mark read")}
>
<.icon name="hero-check" class="w-4 h-4" />
</button>
<% end %>
<button
type="button"
phx-click="dismiss"
phx-value-uuid={n.uuid}
class="btn btn-ghost btn-xs btn-square"
title={gettext("Dismiss")}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
<% end %>
</div>
</li>
<% end %>
</ul>
</div>
</section>
<% end %>
</div>
<%= if length(@notifications) < @total do %>
<div class="mt-4 text-center">
<button type="button" phx-click="load_more" class="btn btn-ghost btn-sm">
{gettext("Load more")}
</button>
</div>
<% end %>
<% end %>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>
"""
end
end