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""" """ 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