Skip to content

Presence via a hook #31

@ndrean

Description

@ndrean

[EDITED]

"Almost" straight from the documentation.

Almost because of the bonus of having a reactive banner on each LiveView.

The banner is rendered in each LiveView (just a different "module_id" assign, which is NOT a dom_id)

<Users.display user_id={@user_id} module_id="users-map" />

the user_id comes from the mount/3

The PhoenixComponent "Users" contains a DOM element that will be filled by JS, where you set phx-udpate="ignore" since the innerContent is managed by the reactive component.

defmodule LiveviewPwaWeb.Users do
  use Phoenix.Component

  @moduledoc false

  attr :user_id, :string, required: true
  attr :module_id, :string, required: true

  def display(assigns) do
    ~H"""
    <div>
      <br />
      <p class="text-sm text-gray-600 mt-4 mb-2">
        User ID:
        <span
          id="user-id"
          class="inline-flex items-center px-2 py-1 text-xs font-medium border border-midnightblue text-midnightblue rounded-full"
        >
          {@user_id}
        </span>
      </p>
      <p id={@module_id} phx-update="ignore"></p>
      <br />
    </div>
    """
  end
end

Use a Channel

Server-side, you just:

  • Presence.track on the "user-id" when it mounts (on each navigation to a given page)
  • push the "user-id" to the client. This is because I wanted to isolate the current user from the other users in the Presence.list
  • get the Presence.list and push it to the connected client(s)
defmodule LiveviewPwa.PresenceChannel do
  use Phoenix.Channel
  alias LiveviewPwa.Presence
  require Logger

  @moduledoc false

  @impl true
  def join("proxy:presence", _params, socket) do
    send(self(), :after_join)
    {:ok, socket}
  end

  @impl true
  def handle_info(:after_join, socket) do
    user_id = socket.assigns.user_id
    {:ok, _} = Presence.track(socket, user_id, %{id: user_id})
    send(self(), :push_users)
    {:noreply, socket}
  end

  def handle_info(:push_users, socket) do
    users = Presence.list(socket)
    #  pass the user_id to the channel
    :ok = push(socket, "user", %{from: socket.assigns.user_id})
    #  push the presence state to the channel
    :ok = push(socket, "presence_state", users)
    {:noreply, socket}
  end
end

In the main JS script ("app.js"), call this function that targets given ids (the "module_id" defined above, set in ) to display the presence list.

It contains the client-side logic of the Channel. The difficulty here is that you want to support navigation, meaning your banner with the users logged-in is present and reactive on each page.

  • a listener channel.on("user") (to detect the current user)
  • a listener on navigation (event "phx:page-loading-stop" to a page that uses this channel.
  • a new Presence object that contains the presence.onSync listener to react on Presence changes when the Channel is connected
  • a rendering JS component (named MountUsers here). It receives the updated "userIDs", the current user ID and the DOM element ID on where to mount)
import { CONFIG } from "@js/main";

export async function setPresence(userSocket, topic, user_token) {
  const [{ Presence }, { useChannel }, { MountUsers }] = await Promise.all([
    import("phoenix"),
    import("@js/user_socket/useChannel"),
    import("@js/components/mountUsers"),
  ]);

  const channel = await useChannel(userSocket, topic, { user_token });
  const presence = new Presence(channel);

  let userID = null;
  let userIDs = [];
  let usersComponent = null;

  // Track user ID sent from backend
  channel.on("user", ({ from }) => {
    userID = from;
  });

  // Utility: determine which DOM ID to use
  const getTargetEl = () => {
    const path = window.location.pathname;
    const id =
      path === "/yjs"
        ? CONFIG.NAVIDS.yjs.id
        : path === "/map"
        ? CONFIG.NAVIDS.map.id
        : CONFIG.NAVIDS.elec.id;
    return document.getElementById(id);
  };

  // Render or update the component, if possible
  const tryRender = () => {
    const el = getTargetEl();
    if (!el) return;

    // Avoid duplicate mounting
    if (usersComponent) {
      if (el === usersComponent.el) {
        console.log("[tryRender] Same element, updating...");
        usersComponent.update({ userIDs, userID, el });
        return;
      } else {
        console.log(
          "[tryRender] Different element, disposing old, mounting new..."
        );
        usersComponent.dispose();
        usersComponent = MountUsers({ userIDs, userID, el });
      }
    } else {
      console.log("[tryRender] No existing component, mounting...");
      usersComponent = MountUsers({ userIDs, userID, el });
    }
    // usersComponent.el = el;
  };

  // Sync presence when changed
  presence.onSync(() => {
    console.log("[presence.onSync]");
    userIDs = presence.list((id, _meta) => id);
    tryRender();
  });

  // Handle LiveView navigation
  window.addEventListener("phx:page-loading-stop", () => {
    console.log("[phx:page-loading-stop]");
    tryRender();
  });
}

where the helper useChannel is:

export function useChannel(socket, topic, params) {
  return new Promise((resolve, reject) => {
    if (!socket) {
      reject(new Error("Socket not found"));
      return;
    }

    const channel = socket.channel(topic, params);
    channel
      .join()
      .receive("ok", () => {
        console.log(`Joined successfully Channel : ${topic}`);
        resolve(channel);
      })

      .receive("error", (resp) => {
        console.log(`Unable to join ${topic}`, resp.reason);
        reject(new Error(resp.reason));
      });
    channel.onClose(() => console.log(`Channel closed: ${topic}`));
  });
}

The last bit is the reactive JS component called "MountUsers" above. . I used SolidJS. One great power is the integration with LiveView. You can return a "disposer" , and possibly any function such as the "update" to mutates the props, thus render selectively. check in "setPresence" how we "dispose" or "udpate".

import { render } from "solid-js/web";
import { For, createSignal, batch } from "solid-js";

export function MountUsers(props) {
  console.log("[MountUsers]");

  const [users, setUsers] = createSignal(props.userIDs);
  const [dom, setDom] = createSignal(props.el);

  const dispose =
    props.el &&
    render(
      () => (
        <p class="text-sm text-midnightblue mt-4 mb-4" id="users">
          <span class="mr-2">{users().length}</span>
          Online user(s): &nbsp
          <For each={users()}>
            {(user) => (
              <span
                class={[
                  Number(user) !== Number(props.userID) ? "bg-bisque" : null,
                  "inline-flex mr-2 items-center px-2 py-1 text-xs font-medium border border-midnightblue text-midnightblue rounded-full",
                ].join(" ")}
              >
                {user}
              </span>
            )}
          </For>
        </p>
      ),
      dom()
    );

  const update = (newProps) => {
    batch(() => {
      setUsers(newProps.userIDs);
      setDom(newProps.el);
    });
  };

  return {
    update,
    dispose,
  };
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions