lib/live_attribute.ex

defmodule LiveAttribute do
  use GenServer
  require Logger
  defstruct [:refresher, :subscribe, :target, :filter, :keys]

  @moduledoc """
  LiveAttribute makes binding updateable values easier. To use it add it to your LiveView using `use LiveAttribute`
  and then use the function `assign_attribute(socket, subscribe_callback, property_callbacks)` to register attributes.

  The attributes will listen to all incoming events and update their assigns of your LiveView automatically, saving
  you the hassle of implementing independent `handle_info()` and `update_...()` calls.

  ## Example using LiveAttribute

  ```
  defmodule UserLive do
    use Phoenix.LiveView
    use LiveAttribute

    def mount(_params, _session, socket) do
      {:ok, assign_attribute(socket, &User.subscribe/0, users: &User.list_users/0)}
    end

    def handle_event("delete_user", %{"id" => user_id}, socket) do
      User.get_user!(user_id)
      |> User.delete_user()

      {:noreply, socket}
    end
  end
  ```


  ## Same Example without LiveAttribute
  ```
  defmodule UserLive do
    use Phoenix.LiveView

    def mount(_params, _session, socket) do
      if connected?(socket), do: User.subscribe()
      {:ok, update_users(socket)}
    end

    defp update_users(socket) do
      users = User.list_users()
      assign(socket, users: users)
    end

    def handle_event("delete_user", %{"id" => user_id}, socket) do
      User.get_user!(user_id)
      |> User.delete_user()

      {:noreply, socket}
    end

    def handle_info({User, [:user, _], _}, socket) do
      {:noreply, update_users(socket)}
    end
  end
  ```

  ### assign\\_attribute(socket, subscribe, filter \\\\ :\\_, refresher)

  * `socket` the LiveView socket where the assigns should be executed on
  * `subscribe` the subscribe callback to start the subscription e.g. `&Users.subscribe/0`
  * `filter` an optional filter if you don't want to update on each event. The filter can either be an expression
    using `:_` as wildcard parameter such as `{User, [:user, :_], :_}`. Alternatively `filter`
    can be a function with one parameter
    _Note_ LiveAttribute is issuing each subscribe call in an isolated helper process, so you only need
    to add filters to reduce the scope of a single subscription.
  * `refresher` the function callback to load the new values after a subscription event has
    fired.

  """

  @doc false
  @type socket :: %Phoenix.LiveView.Socket{}

  @typedoc """
  The refresher list is passed to `assign_attribute()` to know which assigns to update when
  the subscription source issues an event.

  It is a list of `{key, callback}` pairs specifying how to load the new attribute values.
  The `callback` thereby can have optionally one argument to read context from the socket.

  ## refresher() examples
  ```
  # 1. Zero argument callback to update the users list:
  [users: &User.list_all/0]

  # 2. Single argument callback to use the socket state in the update:
  [users: fn socket ->
    User.list_all() -- socket.assigns.blacklist
  end]

  # 3. Special `socket` key to assign multiple values at once manually
  [socket: fn socket ->
    assign(socket,
      users: User.list_all() -- socket.assigns.blacklist,
      last_update: System.os_time()
    )
  end]
  ```


  ## Usage Examples

  ```
  iex> assign_attribute(socket, &User.subscribe(), users: &User.list_all/0)

  iex> assign_attribute(socket, &User.subscribe(), fn socket ->
    assign(socket, users: User.list_all() -- socket.assigns.blacklist)
  end)
  ```
  """
  @type refresher :: [{atom(), (() -> any()) | (socket() -> any())}] | (socket() -> socket())

  @typedoc """
  The filter allows doing optimzation by a) ignoring certain events of the subscription
  source and b) pass event values directly to assign values, instead of using refresher
  functions to re-load them.

  It can be either a match object defining which events should be matched, or a function
  returning `false` when the event should be ignored or a map when it should be processed.
  Keys that are present in the map will be assigned to the socket. (if there are matching
  keys in the refresher list)

  ## Filter function
  The filter function receives the event and should return either `false` or a map of
  the new values:

  ```
  fn event ->
    case event do
      {User, :users_updated, users} -> %{users: users}
      _ -> false
    end
  end)
  ```

  ## Filter object
  Match objects are defined by example of a matching list or tuple. These can be customized
  using two special terms:
  - `:_` the wildcard which matches any value, but ignores it
  - `{:"$", some_key}` - which matches any value, and uses it as update value in the socket assigns

  ## Examples
  ```
  # Let's assumg the `User` module is generating the following event each time
  # the user list is updated: `{User, :users_updated, all_users}`
  # then the following match object will extract the users

  {User, :users_updated, {:"$", :users}}

  # Full function call with match object
  assign_attribute(socket, &User.subscribe/0, users: &User.list/0, {User, :users_updated, {:"$", :users}})


  # Now the same we could get with this function callback instead:
  fn event ->
    case event do
      {User, :users_updated, users} -> %{users: users}
      _ -> false
    end
  end)

  # Full function call with callback
  assign_attribute(socket, &User.subscribe/0, users: &User.list/0,
    fn event ->
      case event do
        {User, :users_updated, users} -> %{users: users}
        _ -> false
      end
    end)
  ```
  """
  @type filter :: atom() | tuple() | list() | (() -> false | %{})

  defmacro __using__(_opts) do
    quote do
      import LiveAttribute,
        only: [update_attribute: 2, assign_attribute: 2, assign_attribute: 3, assign_attribute: 4]

      def handle_info({LiveAttribute, refresher, updates}, socket) do
        {:noreply, refresher.(socket, updates)}
      end
    end
  end

  @doc """
  Shortcut version of `assign_attribute` to capture an attribute configuration
  in a tuple and re-use in multiple LiveViews. This accepts two-element and three-
  element tuples with: `{subscribe, refresher}` or  `{subscribe, filter, refresher}`
  correspondingly

  Use with:
  ```
  socket = assign_attribute(socket, User.live_attribute())
  ```

  When there is an `User` method:

  ```
  defmodule User do
    def live_attribute() do
      {&subscribe/0, users: &list_users/0}
    end
    ...
  end
  ```
  """
  @spec assign_attribute(socket(), tuple()) :: socket()
  def assign_attribute(socket, tuple) when is_tuple(tuple) do
    case tuple do
      {subscribe, refresher} -> assign_attribute(socket, subscribe, refresher)
      {subscribe, filter, refresher} -> assign_attribute(socket, subscribe, filter, refresher)
    end
  end

  @doc """
  ```
  socket = assign_attribute(socket, &User.subscribe/0, users: &User.list/0)
  ```

  `assign_attribute` updates the specified assign keys each time there is a new event sent from
  the subscription source.

  See `refresher()` and `filter()` for advanced usage of these parameters. Simple usage:

  """
  @spec assign_attribute(
          socket(),
          (() -> any()),
          filter(),
          refresher()
        ) :: socket()
  def assign_attribute(socket, subscribe, filter \\ :_, refresher)

  def assign_attribute(socket, subscribe, filter, refresher) when is_list(refresher) do
    keys = Keyword.keys(refresher)

    update_fun = fn socket, updates ->
      Enum.reduce(refresher, socket, fn
        {:socket, value}, socket ->
          value.(socket)

        {key, value}, socket ->
          reload = fn -> LiveAttribute.apply(socket, value) end
          value = Map.get_lazy(updates, key, reload)
          assign(socket, [{key, value}])
      end)
    end

    socket =
      if connected?(socket) do
        {:ok, pid} = LiveAttribute.new(subscribe, filter, update_fun, keys)

        id =
          Keyword.keys(refresher)
          |> Enum.sort()

        meta = Map.get(socket.assigns, :_live_attributes, %{})

        LiveAttribute.stop(Map.get(meta, id))
        meta = Map.put(meta, id, pid)
        assign(socket, _live_attributes: meta)
      else
        socket
      end

    update_fun.(socket, %{})
  end

  def assign_attribute(socket, subscribe, filter, refresher) when is_function(refresher, 1) do
    # Logger.error("using deprecated assign_attribute/4 with fun as refresher")
    if connected?(socket) do
      LiveAttribute.new(subscribe, filter, refresher, [])
    end

    refresher.(socket)
  end

  @doc """
    ```
      socket = update_attribute(socket, :users)
    ```

    Helper method to issue a update callback manually on a live attribute, when there
    is a known update but no subscription event.
  """
  def update_attribute(socket, name) do
    pid =
      Map.get(socket.assigns, :_live_attributes, %{})
      |> Map.get(name)

    if pid != nil do
      refresher = GenServer.call(pid, :get_refresher)
      refresher.(socket, %{})
    else
      Logger.error("update_attribute: #{inspect(name)} is not bound")
      socket
    end
  end

  @doc false
  def new(subscribe, filter, refresher, keys) do
    la = %LiveAttribute{
      filter: filter,
      refresher: refresher,
      subscribe: subscribe,
      target: self(),
      keys: keys
    }

    GenServer.start_link(__MODULE__, la, hibernate_after: 5_000)
  end

  @impl true
  @doc false
  def init(%LiveAttribute{target: target, subscribe: subscribe} = la) do
    Process.monitor(target)
    subscribe.()
    {:ok, la}
  end

  @doc false
  def stop(nil), do: :ok

  def stop(pid) do
    GenServer.cast(pid, :stop)
  end

  @impl true
  @doc false
  def handle_info({:DOWN, _ref, :process, _pid}, state) do
    {:stop, :normal, state}
  end

  @impl true
  @doc false
  def handle_info(
        any,
        %LiveAttribute{target: target, refresher: refresher, filter: filter} = state
      ) do
    case matches?(filter, any) do
      false -> :noop
      %{} = updates -> send(target, {LiveAttribute, refresher, updates})
    end

    {:noreply, state}
  end

  @impl true
  def handle_call(:get_refresher, _from, %LiveAttribute{refresher: refresher} = state) do
    {:reply, refresher, state}
  end

  @impl true
  def handle_cast(:stop, %LiveAttribute{} = state) do
    {:stop, :normal, state}
  end

  @doc false
  def matches?({:"$", key}, value), do: %{key => value}
  def matches?(:_, _any), do: %{}
  def matches?(fun, any) when is_function(fun, 1), do: fun.(any)
  def matches?(same, same), do: %{}

  def matches?(tuple1, tuple2) when is_tuple(tuple1) and is_tuple(tuple2),
    do: matches?(Tuple.to_list(tuple1), Tuple.to_list(tuple2))

  def matches?([head1 | rest1], [head2 | rest2]) do
    case matches?(head1, head2) do
      false ->
        false

      %{} = updates ->
        case matches?(rest1, rest2) do
          false -> false
          %{} = more_updates -> Map.merge(updates, more_updates)
        end
    end
  end

  def matches?(_, _), do: false

  @doc false
  def apply(_socket, refresher) when is_function(refresher, 0), do: refresher.()
  def apply(socket, refresher) when is_function(refresher, 1), do: refresher.(socket)

  defp connected?(socket) do
    case socket do
      %Phoenix.LiveView.Socket{} -> Phoenix.LiveView.connected?(socket)
      %other{} -> other.connected?(socket)
    end
  end

  defp assign(socket, values) do
    case socket do
      %Phoenix.LiveView.Socket{} -> Phoenix.LiveView.assign(socket, values)
      %other{} -> other.assign(socket, values)
    end
  end
end