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"""
"""
end
def render(assigns) do
~H"""
Notifications
<%= if @unread_count > 0 do %>
<% end %>
<%= if @recent == [] do %>
You're all caught up.
<% else %>
<%!-- Plain vertical flex list, not daisyUI `menu`: `menu` sets
`flex-wrap: wrap`, so a tall list past max-h-96 wraps into extra
columns (horizontal overflow) instead of scrolling. --%>
<%= for n <- @recent do %>
<% view = Render.render(n, @locale) %>
<% has_target = (view.link || @default_link) != nil %>
<%!-- 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. --%>
<% end %>
<% end %>
"""
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