defmodule MvWeb.MemberLive.Index.ViewSettings do @moduledoc """ Resolves and persists the member-overview view settings per browser/device. A view-settings value is a map of four keys: * `:density` — `:compact` or `:comfortable` (the row-spacing / compact mode) * `:compact_member` — composite "Member" cell (last+first name) vs separate first-name/last-name columns * `:member_include_email` — whether the email is surfaced (email line in the composite cell, or a separate Email column when the member field is split) * `:compact_address` — composite Address cell (street / postal+city) vs separate street/postal-code/city columns Persistence is per browser (not per account), resolved in priority order from the LiveView connect params, the session, the request cookie, and finally the global defaults (`defaults/0`). The chosen value is written to a long-lived cookie by a small client-side listener; on the next connected mount the client echoes the cookie back through the socket connect params (the connect-info map on a live socket does not expose cookies), and on the disconnected render the cookie is read directly from the request conn. """ alias MvWeb.MemberLive.Index.Cookie @cookie_name "member_view_settings" @session_key "member_view_settings" @connect_param "view_settings" @cookie_max_age 365 * 24 * 60 * 60 @defaults %{ density: :compact, compact_member: true, member_include_email: false, compact_address: true } @densities [:compact, :comfortable] @bool_keys [:compact_member, :member_include_email, :compact_address] @type t :: %{ density: :compact | :comfortable, compact_member: boolean(), member_include_email: boolean(), compact_address: boolean() } @doc "The global default view settings used when nothing is stored." @spec defaults() :: %{ density: :compact, compact_member: true, member_include_email: false, compact_address: true } def defaults, do: @defaults @doc "The cookie name the settings are persisted under (used by the client listener)." @spec cookie_name() :: String.t() def cookie_name, do: @cookie_name @doc "The cookie max-age in seconds (365 days)." @spec cookie_max_age() :: 31_536_000 def cookie_max_age, do: @cookie_max_age @doc """ Parses a raw map (string or atom keys, string/boolean values) into a validated partial settings map with atom keys. Unknown keys and invalid values are dropped, so a caller can safely `Map.merge/2` the result over another source. """ @spec parse(term()) :: %{optional(atom()) => term()} def parse(raw) when is_map(raw) do Enum.reduce(raw, %{}, fn {key, value}, acc -> case parse_pair(to_string(key), value) do {k, v} -> Map.put(acc, k, v) :error -> acc end end) end def parse(_), do: %{} defp parse_pair("density", value) do case parse_density(value) do nil -> :error density -> {:density, density} end end defp parse_pair(key, value) when key in ~w(compact_member member_include_email compact_address) do case parse_bool(value) do nil -> :error bool -> {String.to_existing_atom(key), bool} end end defp parse_pair(_key, _value), do: :error defp parse_density(value) when value in @densities, do: value defp parse_density("compact"), do: :compact defp parse_density("comfortable"), do: :comfortable defp parse_density(_), do: nil defp parse_bool(value) when is_boolean(value), do: value defp parse_bool("true"), do: true defp parse_bool("false"), do: false defp parse_bool(_), do: nil @doc "Reads partial settings from the LiveView session map." @spec get_from_session(map()) :: %{optional(atom()) => term()} def get_from_session(session) when is_map(session), do: parse_json(Map.get(session, @session_key)) def get_from_session(_), do: %{} @doc "Reads partial settings from the request cookie header of a connect-info conn." @spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()} def get_from_cookie(%Plug.Conn{} = conn) do case Plug.Conn.get_req_header(conn, "cookie") do [cookie_header | _rest] -> cookie_header |> Cookie.parse_header() |> Map.get(@cookie_name) |> parse_json() _ -> %{} end end def get_from_cookie(_), do: %{} @doc "Reads partial settings from the LiveView connect params (raw JSON string)." @spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()} def get_from_connect_params(params) when is_map(params), do: parse_json(Map.get(params, @connect_param)) def get_from_connect_params(_), do: %{} @doc """ Resolves the effective view settings for a mount, merging the sources by priority (connect params > session > cookie), falling back per key to the global defaults. """ @spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t() def resolve(session, conn, connect_params \\ nil) do @defaults |> Map.merge(get_from_cookie(conn)) |> Map.merge(get_from_session(session)) |> Map.merge(get_from_connect_params(connect_params)) end @doc "Serializes settings to a JSON string for the client-side cookie writer." @spec to_json(map()) :: String.t() def to_json(settings) when is_map(settings) do settings |> Map.take([:density | @bool_keys]) |> Map.new(fn {:density, density} -> {:density, to_string(density)} {key, value} -> {key, value} end) |> Jason.encode!() end defp parse_json(nil), do: %{} defp parse_json(json) when is_binary(json) do case Jason.decode(json) do {:ok, decoded} -> parse(decoded) _ -> %{} end end defp parse_json(_), do: %{} end