πŸ””User Notifications

A realtime notification/broadcast system for authenticated users.

The Notification Bell Component is embedded in the Side Bar layout and Stacked Layout via core_components.ex. If you want to omit the Notification Bell Component, you can use the :show_notification_bell attribute:

<.layout show_notification_bell={false} current_page={:dashboard} current_user={@current_user} type="sidebar">
  <.container max_width="xl">
    <div>content</div>
  </.container>
</.layout>

You can add the Notification Bell Component anywhere you like with the following code:

<.live_component
  module={PetalProWeb.NotificationBellComponent}
  id={PetalProWeb.NotificationBellComponent.lc_id()}
  current_user={@current_user}
/>

Sending Notifications

Here’s an example of how you could send an Org invitation notification for a User:

alias PetalPro.Notifications
alias PetalPro.Notifications.UserNotificationAttrs

attrs = UserNotificationAttrs.invite_to_org_notification(org, sender_id, recipient_id)

case Notifications.create_user_notification(attrs) do
  {:ok, notification} -> 
    Notifications.broadcast_user_notification(notification)

  {:error, changeset} -> 
    raise "Failed to create notification: #{inspect(changeset)}"
end
  • create_user_notification/1 will create and store the notification.

  • broadcast_user_notification/1 will send a message to the PetalProWeb.UserNotificationChannel.

Devices consuming the UserNotificationChannel will use the information to update the user. For example, any browser tab that includes the Notification Bell Component will automatically update itself (see Consuming Broadcast Messages).

Viewing Notifications

Notifications for the currently authenticated user are visible via the Notification Bell Component:

The Notification Bell Component comes with its own event handlers. This is why it’s implemented as a Live Component:

<.live_component
  module={PetalProWeb.NotificationBellComponent}
  id={PetalProWeb.NotificationBellComponent.lc_id()}
  current_user={@current_user}
/>

Clicking on a notification will navigate the user to a nominated route. The :require_authenticated_user hook has been updated to mark notifications read if the read path matches.

Consuming Broadcast Messages

The UserNotificationChannel is a Channel that is configured in user_socket.ex:

defmodule PetalProWeb.UserSocket do
  use Phoenix.Socket

  channel "user_notifications:*", PetalProWeb.UserNotificationsChannel
  
  ...
end

If you send a broadcast using broadcast_user_notification/1, it will create a Topic with the users' id:

defmodule PetalPro.Notifications do
  def user_notifications_topic(user_id) when not is_nil(user_id), 
    do: "user_notifications:#{user_id}"

  def broadcast_user_notification(%UserNotification{
        id: notification_id,
        type: notification_type,
        recipient_id: recipient_id
      }) do
    PetalProWeb.Endpoint.broadcast(
      user_notifications_topic(recipient_id),
      "notifications_updated",
      %{id: notification_id, type: notification_type}
    )
  end
end

You can then consume this broadcast using any device (e.g. browser or mobile) using a client library that supports Phoenix Channels.

Broadcast messages can only be created and consumed by authenticated users. See user_socket.ex if you want to see how this works.

In the case of Petal Pro, the Notification Bell Component includes code that intercepts broadcast messages. This is achieved via a JavaScript Hook:

  1. The NotificationBellHook consumes broadcast messages client-side

  2. If a broadcast message is raised it pushes an event to the Notification Bell Component

  3. The event causes the Notification Bell Component to update itself

Though data is sent via the Channel, ultimately the data that is displayed by the Notification Bell Component is loaded from the database (not the Channel).

The Read Path

If you look at UserNotificationAttrs.invite_to_org_notification/3:

def invite_to_org_notification(%Org{} = org, sender_id, recipient_id) do
  %{
    read_path: ~p"/app/users/org-invitations",
    type: :invited_to_org,
    recipient_id: recipient_id,
    sender_id: sender_id,
    org_id: org.id,
    message: gettext("You have been invited to join the %{org_name} organisation!", org_name: org.name)
  }
end

You’ll see that it has a read_path property. This must be a verified route - if the user navigates to this page, then the notification is marked as read (handled in the :require_authenticated_user on_mount hook).

Creating your own notification

If you want to create your own user notification, you'll need to implement the following:

  1. Create a helper function in user_notification_attrs.ex

  2. Add a notification_type to user_notification.ex

  3. Add code to create/broadcast the new message

  4. Add a notification_item to notification_components.ex

Let's create a notification for a user when they have been promoted to org admin!

Create the helper function

In user_notification_attrs.ex, add the following function:

defmodule PetalPro.Notifications.UserNotificationAttrs do
  ...

  def promote_to_org_admin(%Org{} = org, sender_id, recipient_id) do
    %{
      read_path: ~p"/app/org/#{org.slug}/team",
      type: :promoted_to_org_admin,
      recipient_id: recipient_id,
      sender_id: sender_id,
      org_id: org.id,
      message: gettext("You have been promoted to org admin for %{org_name}!", org_name: org.name)
    }
  end
  
  ...
end

Note that the read_path points to the org/team route - a path that would be inaccessible if the user was not an org admin.

Add the Notification Type

In user_notification.ex, add a new Notification Type:

defmodule PetalPro.Notifications.UserNotification do
  ...

  @notification_types [:invited_to_org, :promoted_to_org_admin]

  ...
end

This adds :promoted_to_org_admin as an allowed type.

Add code to broadcast the message

In orgs.ex modify update_membership/2:

defmodule PetalPro.Orgs do
  ...
  
  def update_membership(org_admin, %Membership{} = membership, attrs) do
    changeset = Membership.update_changeset(membership, attrs)
    org = get_org_by_id!(membership.org_id)

    Ecto.Multi.new()
    |> Ecto.Multi.update(:membership, changeset)
    |> maybe_create_admin_notification(org, org_admin, changeset)
    |> Repo.transaction()
    |> case do
      {:ok, %{membership: membership} = result} ->
        result[:user_notification] && Notifications.broadcast_user_notification(result[:user_notification])

        {:ok, membership}

      {:error, :membership, error, _} ->
        {:error, error}

      {:error, :user_notification, error, _} ->
        {:error, error}

      {:error, error} ->
        {:error, error}
    end
  end
  
  defp maybe_create_admin_notification(multi, org, org_admin, changeset) do
    promoted = Ecto.Changeset.changed?(changeset, :role, to: :admin)

    if promoted do
      recipient_id = Ecto.Changeset.get_field(changeset, :user_id)
      user_notification_attrs = UserNotificationAttrs.promote_to_org_admin(org, org_admin.id, recipient_id)

      maybe_create_user_notification_multi(multi, user_notification_attrs, recipient_id)
    else
      multi
    end
  end
  
  ...
end

You'll need to update any call to update_membership. E.g:

@impl true
def handle_event("save", %{"membership" => params}, socket) do
  current_user = socket.assigns.current_user
  membership = socket.assigns.membership
  
  case Orgs.update_membership(current_user, membership, params) do
    ...
  end
end

This should allow the notification to be created/broadcast whenever a user's membership role has been changed to :admin. However, the Notification Bell Component needs to be updated to allow for the new Notification Type.

Add a Notification Item

Finally, the last thing we need to do is update the item template for the Notification Bell Component. You can do this by updating notification_components.ex:

defmodule PetalProWeb.Notifications.Components do
  ...

  def notification_item(%{notification: %UserNotification{type: :promoted_to_org_admin}} = assigns) do
    ~H"""
    <.link
      href={"#{@notification.read_path}"}
      class="flex p-2 transition-colors rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
    >
      <.avatar
        random_color
        name={user_name(@notification.sender)}
        src={user_avatar_url(@notification.sender)}
        class="flex-shrink-0"
      />
      <div class="flex flex-col my-auto ml-4 space-y-1 text-sm text-gray-700 dark:text-gray-100">
        <p>
          <%= gettext("%{name} has promoted you to admin for the organisation %{org_name}.",
            name: "<span class='font-medium'>#{get_sender_name(@notification.sender)}</span>",
            org_name: "<span class='font-medium'>#{@notification.org.name}</span>"
          )
          |> raw() %>
        </p>
        <p class="text-xs text-gray-500"><%= humanized_time_since(@notification.inserted_at) %></p>
      </div>
      <span class="flex-grow" />
      <span
        :if={is_nil(@notification.read_at)}
        class="flex-shrink-0 w-2 h-2 my-auto mr-2 bg-red-500 rounded-full"
      />
    </.link>
    """
  end
  
  ...
end

This pattern matches on the new Notification Type :promoted_to_org_admin. If we didn't add the new call to notification_item, then any page with the Notification Bell will cause an error.

Just like the :invited_to_org notification, if the user clicks on the notification to navigate to the page, then the notification will be marked as read!

Last updated