Packages
phoenix_kit
1.7.165
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
Current section
Files
lib/phoenix_kit_web/live/notifications_bell.ex
defmodule PhoenixKitWeb.Live.NotificationsBell do
@moduledoc """
Nested LiveView that renders the notifications bell in the global layout.
Embedded from `layout_wrapper.ex` via `live_render/3` with `sticky: true`
so it keeps its socket across page navigations and its own PubSub
subscription stays live. It owns its `handle_info` callbacks for
`{:notification_created, _}` / `{:notification_seen, _}` /
`{:notification_dismissed, _}` / `{:notifications_bulk_updated, _}`,
refreshing the badge count and dropdown contents on each event.
Not routable on its own — mount expects `user_uuid` in the nested session.
"""
use Phoenix.LiveView, layout: false
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1]
alias PhoenixKit.Notifications
alias PhoenixKit.Notifications.Events
alias PhoenixKit.Notifications.Render
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
def mount(_params, %{"user_uuid" => user_uuid} = session, socket) when is_binary(user_uuid) do
if connected?(socket), do: Events.subscribe(user_uuid)
# Recipient's current locale, threaded from the layout so click-through links
# land on the right locale-prefixed path (the bell is a sticky nested LV with
# no locale of its own — without this, Routes.path would use the default).
{:ok,
socket
|> assign(:user_uuid, user_uuid)
|> assign(:locale, session["locale"])
|> refresh()}
end
# Graceful fallback for embeddings without a user session — renders an
# empty element so the layout doesn't crash for anonymous visitors.
def mount(_params, _session, socket) do
{:ok, socket |> assign(:user_uuid, nil) |> assign(:locale, nil)}
end
# ── Events ─────────────────────────────────────────────────────────
def handle_event("open_notification", %{"uuid" => uuid}, socket) do
user_uuid = socket.assigns.user_uuid
case Notifications.mark_seen(user_uuid, uuid) do
{:ok, notification} ->
# Effective target: the notification's own link, else the host's
# configured catch-all default. Both are nil → no navigation (the row
# is informational; the click still cleared its unread state above).
link = Render.render(notification, socket.assigns.locale).link
target = link || socket.assigns.default_link
case target do
nil ->
warn_unlinked(notification)
{:noreply, refresh(socket)}
target ->
{:noreply, push_navigate(socket, to: target)}
end
_ ->
{:noreply, refresh(socket)}
end
end
def handle_event("dismiss", %{"uuid" => uuid}, socket) do
Notifications.dismiss(socket.assigns.user_uuid, uuid)
{:noreply, refresh(socket)}
end
def handle_event("mark_all_seen", _params, socket) do
Notifications.mark_all_seen(socket.assigns.user_uuid)
{:noreply, refresh(socket)}
end
# ── PubSub ─────────────────────────────────────────────────────────
def handle_info({event, _payload}, socket)
when event in [
:notification_created,
:notification_seen,
:notification_dismissed,
:notifications_bulk_updated
] do
{:noreply, refresh(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
# ── Render ─────────────────────────────────────────────────────────
def render(%{user_uuid: nil} = assigns) do
~H"""
<div></div>
"""
end
def render(assigns) do
~H"""
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle" title="Notifications">
<div class="indicator">
<.icon name="hero-bell" class="w-5 h-5" />
<%= if @unread_count > 0 do %>
<span class="badge badge-xs badge-primary indicator-item">
{display_count(@unread_count)}
</span>
<% end %>
</div>
</label>
<div
tabindex="0"
class="dropdown-content z-[100] mt-3 w-80 max-w-[95vw] card card-compact bg-base-100 shadow-xl border border-base-200"
>
<div class="card-body p-0">
<div class="flex items-center justify-between px-4 py-3 border-b border-base-200">
<span class="font-semibold">Notifications</span>
<%= if @unread_count > 0 do %>
<button
type="button"
phx-click="mark_all_seen"
class="btn btn-ghost btn-xs"
>
Mark all seen
</button>
<% end %>
</div>
<%= if @recent == [] do %>
<div class="px-4 py-8 text-center text-sm text-base-content/60">
You're all caught up.
</div>
<% else %>
<ul class="menu menu-sm max-h-96 overflow-y-auto p-0">
<%= for n <- @recent do %>
<% view = Render.render(n, @locale) %>
<% has_target = (view.link || @default_link) != nil %>
<li class={[
"border-b border-base-200 last:border-b-0",
is_nil(n.seen_at) && "bg-primary/5"
]}>
<%!-- Always a button (clicking marks the notification seen);
pointer cursor only when there's somewhere to navigate, so
a link-less notification reads as informational, not broken. --%>
<button
type="button"
phx-click="open_notification"
phx-value-uuid={n.uuid}
class={[
"flex items-start gap-3 w-full px-4 py-3 hover:bg-base-200 text-left",
if(has_target, do: "cursor-pointer", else: "cursor-default")
]}
>
<.icon name={view.icon} class="w-5 h-5 mt-0.5 shrink-0 text-base-content/70" />
<div class="flex-1 min-w-0">
<p class={[
"text-sm",
is_nil(n.seen_at) && "font-semibold"
]}>
{view.text}
</p>
<p class="text-xs text-base-content/50 mt-0.5">
{relative_time(n.inserted_at)}
</p>
</div>
</button>
</li>
<% end %>
</ul>
<% end %>
</div>
</div>
</div>
"""
end
# ── Helpers ────────────────────────────────────────────────────────
defp refresh(socket) do
user_uuid = socket.assigns.user_uuid
socket
|> assign(:unread_count, Notifications.count_unread(user_uuid))
|> assign(:recent, Notifications.recent_for_user(user_uuid, 10))
|> assign(:default_link, default_link(socket.assigns[:locale]))
end
# Catch-all destination for notifications without a link of their own, from
# the `notification_default_link` setting. Defaults to the user dashboard
# out of the box — it's authenticated-only (every recipient can reach it),
# unlike role-gated /admin. Blank → nil (non-navigating). Built through
# Routes.path so it carries the URL prefix + the recipient's locale.
defp default_link(locale) do
# Cache-backed read: refresh/1 runs on mount (twice) and on every notification
# PubSub event, so this is a hot path — the uncached get_setting/2 would hit
# the DB each time. The cache is invalidated when the setting is saved.
case Settings.get_setting_cached("notification_default_link", "/dashboard")
|> to_string()
|> String.trim() do
"" ->
nil
# Guard the built-in default: /dashboard 404s when the user dashboard is
# disabled, so fall back to no-op rather than send the user to a dead route.
"/dashboard" ->
if PhoenixKit.Config.user_dashboard_enabled?(),
do: Routes.path("/dashboard", locale: locale),
else: nil
"/" <> _ = path ->
Routes.path(path, locale: locale)
_ ->
nil
end
end
# Dev nudge: when a clicked notification has neither its own link nor a
# configured default, log how to wire it. Off unless the host opts in with
# `config :phoenix_kit, warn_unlinked_notifications: true` — never noise in prod.
defp warn_unlinked(notification) do
if Application.get_env(:phoenix_kit, :warn_unlinked_notifications, false) do
action = notification.activity && Map.get(notification.activity, :action)
Logger.warning(
"Notification #{inspect(action)} has no click-through link. Set a " <>
"\"notification_link\" (built via PhoenixKit.Utils.Routes.path/1) in the activity " <>
"metadata when logging it, or set the \"notification_default_link\" setting for a catch-all."
)
end
end
defp display_count(n) when n > 99, do: "99+"
defp display_count(n), do: Integer.to_string(n)
defp relative_time(%DateTime{} = dt) do
seconds = DateTime.diff(DateTime.utc_now(), dt)
cond do
seconds < 60 -> "just now"
seconds < 3600 -> "#{div(seconds, 60)}m ago"
seconds < 86_400 -> "#{div(seconds, 3600)}h ago"
seconds < 604_800 -> "#{div(seconds, 86_400)}d ago"
true -> Calendar.strftime(dt, "%b %d")
end
end
defp relative_time(_), do: ""
end