commit c401961e70bcf9ddeee38950fc19a8052c112b41 Author: Mikko Ahlroth Date: Sun Oct 9 12:46:45 2022 +0300 Hackfest diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..56fb2e3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs,heex}"], + plugins: [Phoenix.LiveView.HTMLFormatter] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c31d4b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +talk_tool-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +/priv/db/ +/.env diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..2af49a9 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 25.1.1 +elixir 1.14.0-otp-25 diff --git a/README.md b/README.md new file mode 100644 index 0000000..018ec1f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# TalkTool diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6a0bed2 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,24 @@ +import Config + +config :talk_tool, TTAdminUI.Endpoint, + render_errors: [accepts: ~w(html json)], + pubsub_server: TTAdminUI.PubSub, + code_reloader: config_env() == :dev, + debug_errors: config_env() == :dev, + check_origin: config_env() == :prod + +config :talk_tool, TTClientUI.Endpoint, + render_errors: [accepts: ~w(html json), root_layout: {TTClientUI.ErrorView, :root}], + pubsub_server: TTClientUI.PubSub, + code_reloader: config_env() == :dev, + debug_errors: config_env() == :dev, + check_origin: config_env() == :prod + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :talk_tool, + compile_env: Mix.env() + +config :phoenix, :json_library, Jason diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..8171605 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,60 @@ +import Config +import TalkTool.ConfigHelpers, only: [get_env: 3, get_env: 2, get_env: 1] + +if Config.config_env() == :dev do + DotenvParser.load_file(".env") +end + +config :talk_tool, + db_dir: Application.app_dir(:talk_tool, "priv") |> Path.join(get_env("DB_DIR", "db")) + +config :talk_tool, TTAdminUI.Endpoint, + http: [port: get_env("ADMIN_PORT", 6969, :int)], + url: [ + host: get_env("ADMIN_HOST", "localhost"), + port: get_env("ADMIN_HOST_PORT", 6969, :int), + scheme: get_env("ADMIN_SCHEME", "http") + ], + + # LiveView + live_view: [signing_salt: get_env("LIVE_VIEW_SIGNING_SALT", "FxvcOwl5788vwgJD")] + +config :talk_tool, TTClientUI.Endpoint, + http: [port: get_env("CLIENT_PORT", 4242, :int)], + url: [ + host: get_env("CLIENT_HOST", "localhost"), + port: get_env("CLIENT_HOST_PORT", 4242, :int), + scheme: get_env("CLIENT_SCHEME", "http") + ], + + # LiveView + live_view: [signing_salt: get_env("LIVE_VIEW_SIGNING_SALT", "FxvgVmWxHFVVmwDB")] + +case config_env() do + :dev -> + config :talk_tool, TTAdminUI.Endpoint, + secret_key_base: "asd+0f98dfasd+098dfas+0d98dfa+0sd98df0+a9s8df+09a8sdf+09a8sdf+098adf" + + config :talk_tool, TTClientUI.Endpoint, + secret_key_base: "aw.4,5nmaw3.,4m5wrnraw3.,5mnaaw34,.m5nwaw3.,m5naw3.,m5na.w3,m5na.,w3mn5" + + config :talk_tool, TTAdminUI.Endpoint, + live_reload: [ + patterns: [ + ~r{priv/static/admin/.*(js|s?css|png|jpeg|jpg|gif|svg)$}, + ~r{lib/(t_t_admin_u_i|talk_tool|t_t_u_i_common|t_t_admin)/.*(\.ex)$}, + ~r{lib/(t_t_admin_u_i)/.*(\.[hl]?eex)$} + ] + ] + + config :talk_tool, TTClientUI.Endpoint, + live_reload: [ + patterns: [ + ~r{priv/static/client/.*(js|s?css|png|jpeg|jpg|gif|svg)$}, + ~r{lib/(t_t_client_u_i|talk_tool|t_t_u_i_common|t_t_admin)/.*(\.ex)$}, + ~r{lib/(t_t_client_u_i)/.*(\.[hl]?eex)$} + ] + ] + + config :phoenix, :stacktrace_depth, 20 +end diff --git a/lib/t_t_admin/presentation/pubsub.ex b/lib/t_t_admin/presentation/pubsub.ex new file mode 100644 index 0000000..e3cf7b4 --- /dev/null +++ b/lib/t_t_admin/presentation/pubsub.ex @@ -0,0 +1,58 @@ +defmodule TTAdmin.Presentation.PubSub do + alias Phoenix.PubSub + alias TTAdmin.Schemas.Reaction + alias TTAdmin.Schemas.Presentation + alias TTAdmin.Schemas.Question + + @spec publish_presentation(Presentation.t()) :: :ok + def publish_presentation(presentation) do + PubSub.broadcast!( + __MODULE__, + "#{presentation.id}", + {:update_presentation, presentation} + ) + end + + @spec listen_presentation(Presentation.t()) :: :ok | {:error, term()} + def listen_presentation(presentation) do + PubSub.subscribe(__MODULE__, "#{presentation.id}") + end + + @spec publish_admin_pid(Presentation.t(), pid() | nil) :: :ok + def publish_admin_pid(presentation, pid) do + PubSub.broadcast!(__MODULE__, "#{presentation.id}:pid", {:new_admin_pid, pid}) + end + + @spec listen_admin_pid(Presentation.t()) :: :ok | {:error, term()} + def listen_admin_pid(presentation) do + PubSub.subscribe(__MODULE__, "#{presentation.id}:pid") + end + + @spec publish_reaction(Reaction.t()) :: :ok + def publish_reaction(reaction) do + PubSub.broadcast!( + __MODULE__, + "#{reaction.presentation_id}:reactions", + {:new_reaction, reaction.presentation_id, reaction} + ) + end + + @spec listen_reactions(Presentation.t()) :: :ok | {:error, term()} + def listen_reactions(presentation) do + PubSub.subscribe(__MODULE__, "#{presentation.id}:reactions") + end + + @spec publish_question(Question.t()) :: :ok + def publish_question(question) do + PubSub.broadcast!( + __MODULE__, + "#{question.presentation_id}:questions", + {:new_question, question.presentation_id, question} + ) + end + + @spec listen_questions(Presentation.t()) :: :ok | {:error, term()} + def listen_questions(presentation) do + PubSub.subscribe(__MODULE__, "#{presentation.id}:questions") + end +end diff --git a/lib/t_t_admin/presentation/server.ex b/lib/t_t_admin/presentation/server.ex new file mode 100644 index 0000000..3a2b98e --- /dev/null +++ b/lib/t_t_admin/presentation/server.ex @@ -0,0 +1,108 @@ +defmodule TTAdmin.Presentation.Server do + use GenServer, restart: :transient + + import TalkTool.TypedStruct + + alias TTAdmin.Schemas.Presentation + alias TTAdmin.Storage.DB + alias TTAdmin.Presentation.PubSub + + defmodule Options do + deftypedstruct(%{ + id: Presentation.id() + }) + end + + defmodule State do + deftypedstruct(%{ + presentation: Presentation.t(), + admin: {pid() | nil, nil} + }) + end + + @spec start_link(Options.t()) :: GenServer.on_start() + def start_link(opts) do + GenServer.start_link(__MODULE__, opts.id, name: via(opts.id)) + end + + @impl GenServer + def init(id) do + presentation = DB.get_presentation!(id) + + {:ok, %State{presentation: presentation}} + end + + @impl GenServer + def handle_call(msg, from, state) + + def handle_call(:get, _from, state) do + {:reply, state.presentation, state} + end + + def handle_call(:get_admin, _from, state) do + {:reply, state.admin, state} + end + + def handle_call({:update, changeset}, _from, state) do + case Ecto.Changeset.apply_action(changeset, :update) do + {:ok, presentation} -> + DB.put_presentation(presentation) + PubSub.publish_presentation(presentation) + {:reply, {:ok, presentation}, %State{state | presentation: presentation}} + + {:error, changeset} -> + {:reply, {:error, changeset}, state} + end + end + + def handle_call({:set_admin, pid}, from, state) do + new_admin = + cond do + pid == nil and state.admin == from -> nil + pid != nil -> pid + true -> state.admin + end + + PubSub.publish_admin_pid(state.presentation, new_admin) + {:reply, :ok, %State{state | admin: new_admin}} + end + + @spec get_server!(Presentation.id()) :: GenServer.name() + def get_server!(id) do + case Registry.lookup(__MODULE__.Registry, id) do + [{pid, _}] -> + pid + + _ -> + case DynamicSupervisor.start_child(__MODULE__.Supervisor, {__MODULE__, %Options{id: id}}) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + {:error, error} -> raise DB.NoResultsError, error + end + end + end + + @spec get(GenServer.name()) :: Presentation.t() + def get(server) do + GenServer.call(server, :get) + end + + @spec update(GenServer.name(), Ecto.Changeset.t()) :: + {:ok, Presentation.t()} | {:error, Ecto.Changeset.t()} + def update(server, changeset) do + GenServer.call(server, {:update, changeset}) + end + + @spec get_admin(GenServer.name()) :: pid() | nil + def get_admin(server) do + GenServer.call(server, :get_admin) + end + + @spec set_admin(GenServer.name(), pid() | nil) :: :ok + def set_admin(server, pid) do + GenServer.call(server, {:set_admin, pid}) + end + + @spec via(Presentation.id()) :: GenServer.name() + defp via(id), do: {:via, Registry, {__MODULE__.Registry, id}} +end diff --git a/lib/t_t_admin/presentation/storage.ex b/lib/t_t_admin/presentation/storage.ex new file mode 100644 index 0000000..e69de29 diff --git a/lib/t_t_admin/schemas/presentation.ex b/lib/t_t_admin/schemas/presentation.ex new file mode 100644 index 0000000..4c693cf --- /dev/null +++ b/lib/t_t_admin/schemas/presentation.ex @@ -0,0 +1,32 @@ +defmodule TTAdmin.Schemas.Presentation do + import TalkTool.TypedSchema + + alias Ecto.Changeset + + @type id() :: Ecto.UUID.t() + + deftypedschema(:embedded) do + field(:name, :string, String.t()) + field(:can_screenshot, :boolean, boolean()) + end + + @spec create_changeset(map()) :: Changeset.t() + def create_changeset(params) do + %__MODULE__{} + |> Changeset.cast(params, [:name]) + |> Changeset.validate_required([:name]) + |> Changeset.put_change(:id, Ecto.UUID.autogenerate()) + |> Changeset.put_change(:can_screenshot, false) + end + + @spec update_screenshot(t(), map()) :: Changeset.t() + def update_screenshot(presentation, params) do + presentation + |> Changeset.cast(params, [:can_screenshot]) + |> Changeset.validate_required([:can_screenshot]) + |> Changeset.validate_change(:can_screenshot, fn + :can_screenshot, val when is_boolean(val) -> [] + :can_screenshot, _ -> [can_screenshot: "Must be boolean."] + end) + end +end diff --git a/lib/t_t_admin/schemas/question.ex b/lib/t_t_admin/schemas/question.ex new file mode 100644 index 0000000..478fe46 --- /dev/null +++ b/lib/t_t_admin/schemas/question.ex @@ -0,0 +1,28 @@ +defmodule TTAdmin.Schemas.Question do + import TalkTool.TypedSchema + + alias Ecto.Changeset + alias TTAdmin.Schemas.Presentation + + @type id() :: Ecto.UUID.t() + + deftypedschema(:embedded) do + field(:presentation_id, Ecto.UUID, Presentation.id() | nil) + + field(:text, :string, String.t()) + field(:at, :utc_datetime, DateTime.t()) + field(:answered, :boolean, boolean()) + field(:picture, :binary, binary() | nil) + end + + @spec create_changeset(Presentation.t(), map()) :: Changeset.t() + def create_changeset(presentation, params) do + %__MODULE__{} + |> Changeset.cast(params, [:text, :picture]) + |> Changeset.validate_required([:text]) + |> Changeset.put_change(:presentation_id, presentation.id) + |> Changeset.put_change(:at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> Changeset.put_change(:id, Ecto.UUID.autogenerate()) + |> Changeset.put_change(:answered, false) + end +end diff --git a/lib/t_t_admin/schemas/reaction.ex b/lib/t_t_admin/schemas/reaction.ex new file mode 100644 index 0000000..0378322 --- /dev/null +++ b/lib/t_t_admin/schemas/reaction.ex @@ -0,0 +1,26 @@ +defmodule TTAdmin.Schemas.Reaction do + import TalkTool.TypedSchema + + alias Ecto.Changeset + alias TTAdmin.Schemas.Presentation + + @type id() :: Ecto.UUID.t() + + deftypedschema(:embedded) do + field(:presentation_id, Ecto.UUID, Presentation.id() | nil) + + field(:emoji, :string, String.t()) + field(:at, :utc_datetime, DateTime.t()) + end + + @spec create_changeset(Presentation.t(), map()) :: Changeset.t() + def create_changeset(presentation, params) do + %__MODULE__{} + |> Changeset.cast(params, [:emoji]) + |> Changeset.validate_required([:emoji]) + |> Changeset.validate_length(:emoji, is: 1) + |> Changeset.put_change(:presentation_id, presentation.id) + |> Changeset.put_change(:at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> Changeset.put_change(:id, Ecto.UUID.autogenerate()) + end +end diff --git a/lib/t_t_admin/storage/db.ex b/lib/t_t_admin/storage/db.ex new file mode 100644 index 0000000..1ea6c9b --- /dev/null +++ b/lib/t_t_admin/storage/db.ex @@ -0,0 +1,134 @@ +defmodule TTAdmin.Storage.DB do + alias TTAdmin.Schemas + alias Ecto.Changeset + + defmodule NoResultsError do + defexception [:message] + end + + @spec get_presentations() :: [Schemas.Presentation.t()] + def get_presentations() do + CubDB.get(__MODULE__, :presentations, []) |> Enum.map(&load_presentation/1) + end + + @spec get_presentation!(Schemas.Presentation.id()) :: Schemas.Presentation.t() | no_return() + def get_presentation!(id) do + p = + get_presentations() + |> Enum.find(&(&1.id == id)) + + if p != nil do + load_presentation(p) + else + raise NoResultsError, "Cannot find presentation with id #{inspect(id)}" + end + end + + @spec create_presentation(Changeset.t()) :: + {:ok, Schemas.Presentation.t()} | {:error, Changeset.t()} + def create_presentation(changeset) do + case Changeset.apply_action(changeset, :insert) do + {:ok, data} -> + CubDB.update(__MODULE__, :presentations, [data], fn old -> + [data | old] + end) + + {:ok, data} + + {:error, cset} -> + {:error, cset} + end + end + + @spec put_presentation(Schemas.Presentation.t()) :: :ok + def put_presentation(presentation) do + presentation = %{ + id: presentation.id, + name: presentation.name + } + + CubDB.update(__MODULE__, :presentations, [presentation], fn old -> + i = Enum.find_index(old, &(&1.id == presentation.id)) + + if i != nil do + List.replace_at(old, i, presentation) + else + [presentation | old] + end + end) + end + + @spec create_reaction(Changeset.t()) :: {:ok, Schemas.Reaction.t()} | {:error, Changeset.t()} + def create_reaction(changeset) do + case Changeset.apply_action(changeset, :insert) do + {:ok, data} -> + CubDB.update(__MODULE__, reactions_id(data.presentation_id), [data], fn old -> + [data | old] + end) + + {:ok, data} + + {:error, cset} -> + {:error, cset} + end + end + + @spec get_questions(Schemas.Presentation.t()) :: [Schemas.Question.t()] + def get_questions(presentation) do + CubDB.get(__MODULE__, questions_id(presentation.id), []) + end + + @spec create_question(Changeset.t()) :: {:ok, Schemas.Question.t()} | {:error, Changeset.t()} + def create_question(changeset) do + case Changeset.apply_action(changeset, :insert) do + {:ok, data} -> + CubDB.update(__MODULE__, questions_id(data.presentation_id), [data], fn old -> + [data | old] + end) + + {:ok, data} + + {:error, cset} -> + {:error, cset} + end + end + + @spec put_question(Schemas.Presentation.t(), Schemas.Question.t()) :: :ok + def put_question(presentation, question) do + CubDB.update(__MODULE__, questions_id(presentation.id), [question], fn old -> + i = Enum.find_index(old, &(&1.id == question.id)) + + if i != nil do + List.replace_at(old, i, question) + else + [question | old] + end + end) + end + + @spec remove_question(Schemas.Presentation.t(), Schemas.Question.id()) :: :ok + def remove_question(presentation, id) do + CubDB.update(__MODULE__, questions_id(presentation.id), [], fn old -> + Enum.reject(old, &(&1.id == id)) + end) + end + + @spec load_presentation(map()) :: Presentation.t() + defp load_presentation(data) do + %Schemas.Presentation{ + id: data.id, + name: data.name, + can_screenshot: false + } + end + + @spec reactions_id(Schemas.Presentation.id()) :: String.t() + defp reactions_id(id) do + "reactions-#{id}" + end + + @spec questions_id(Schemas.Presentation.id()) :: String.t() + defp questions_id(id) do + "questions-#{id}" + end +end diff --git a/lib/t_t_admin_u_i/endpoint.ex b/lib/t_t_admin_u_i/endpoint.ex new file mode 100644 index 0000000..05fe81b --- /dev/null +++ b/lib/t_t_admin_u_i/endpoint.ex @@ -0,0 +1,103 @@ +defmodule TTAdminUI.Endpoint do + use Phoenix.Endpoint, otp_app: :talk_tool + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_talk_tool_admin_key", + signing_salt: "whoahman" + ] + + socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug(Plug.Static, + at: "/static/", + from: "priv/static", + gzip: false, + only: ~w(admin client common) + ) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) + plug(Phoenix.LiveReloader) + plug(Phoenix.CodeReloader) + end + + plug(Plug.RequestId) + + plug(Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + plug(TTAdminUI.Router) +end + +# defmodule TTAdminUI.Endpoint do +# import TalkTool.PlugHelpers + +# @behaviour Phoenix.Endpoint +# @behaviour Plug + +# @config Phoenix.Endpoint.Supervisor.config(:talk_tool, __MODULE__) + +# @plugs [ +# if @config[:code_reloader] do +# build_plug(Phoenix.LiveReloader) +# end, +# if @config[:code_reloader] do +# build_plug(Phoenix.CodeReloader) +# end, +# build_plug(TTAdminUI.Router) +# ] + +# @impl Phoenix.Endpoint +# def init(_key, config) do +# {:ok, config} +# end + +# @impl Plug +# def init(opts) do +# opts +# end + +# @impl Plug +# def call(conn, _opts) do +# run_plug(@plugs, conn) +# end + +# defp run_plug(plugs, conn) + +# defp run_plug([], conn), do: conn + +# defp run_plug([nil | rest], conn), do: run_plug(rest, conn) + +# defp run_plug([{plug, opts} | rest], conn) do +# case plug.(conn, opts) do +# %Plug.Conn{halted: true} = new_conn -> +# new_conn + +# %Plug.Conn{} = new_conn -> +# run_plug(rest, new_conn) + +# other -> +# raise "Plug did not return Plug.Conn, instead got: #{inspect(other)}" +# end +# end + +# defp pubsub_server!() do +# Keyword.fetch!(@config, :pubsub_server) +# end +# end diff --git a/lib/t_t_admin_u_i/layout_view.ex b/lib/t_t_admin_u_i/layout_view.ex new file mode 100644 index 0000000..fe660ec --- /dev/null +++ b/lib/t_t_admin_u_i/layout_view.ex @@ -0,0 +1,7 @@ +defmodule TTAdminUI.LayoutView do + use Phoenix.View, root: Path.expand("#{__DIR__}/templates"), namespace: TTAdminUI + + alias TTAdminUI.Router.Helpers, as: Routes + + def config(), do: {__MODULE__, "live.html"} +end diff --git a/lib/t_t_admin_u_i/live/components/presentation.ex b/lib/t_t_admin_u_i/live/components/presentation.ex new file mode 100644 index 0000000..f77e281 --- /dev/null +++ b/lib/t_t_admin_u_i/live/components/presentation.ex @@ -0,0 +1,34 @@ +defmodule TTAdminUI.Live.Components.Presentation do + use Phoenix.Component + + alias TTClientUI.Router.Helpers, as: ClientRoutes + + def question(assigns) do + ~H""" +
+

<%= @question.text %>

+ + <%= if @question.picture do %> + + <% end %> + + <%= if not @question.answered do %> + + <% else %> + + <% end %> + + +
+ """ + end + + def qr(assigns) do + ~H""" + QRCode.Svg.to_base64()}"} + class="qr" + /> + """ + end +end diff --git a/lib/t_t_admin_u_i/live/create_presentation.ex b/lib/t_t_admin_u_i/live/create_presentation.ex new file mode 100644 index 0000000..4f7d7ff --- /dev/null +++ b/lib/t_t_admin_u_i/live/create_presentation.ex @@ -0,0 +1,54 @@ +defmodule TTAdminUI.Live.CreatePresentation do + use Phoenix.LiveView, layout: TTAdminUI.LayoutView.config() + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTAdminUI.Router.Helpers, as: Routes + + alias TTAdmin.Schemas.Presentation + alias TTAdmin.Storage.DB + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + loading = not Phoenix.LiveView.connected?(socket) + + changeset = + if not loading do + Presentation.create_changeset(%{}) + end + + {:ok, + assign(socket, + page_title: "Create New", + loading: loading, + changeset: changeset, + submit_error: false + )} + end + + @impl Phoenix.LiveView + def handle_event(evt, params, socket) + + def handle_event("change", %{"presentation" => presentation}, socket) do + changeset = Presentation.create_changeset(presentation) + + {:noreply, assign(socket, changeset: changeset)} + end + + def handle_event("submit", %{"presentation" => presentation}, socket) do + changeset = Presentation.create_changeset(presentation) + + case Ecto.Changeset.apply_action(changeset, :create) do + {:ok, presentation} -> + DB.put_presentation(presentation) + + {:noreply, + push_navigate(socket, + to: Routes.live_path(socket, TTAdminUI.Live.ViewPresentation, presentation.id) + )} + + {:error, changeset} -> + {:noreply, assign(socket, changeset: changeset, submit_error: true)} + end + end +end diff --git a/lib/t_t_admin_u_i/live/create_presentation.html.heex b/lib/t_t_admin_u_i/live/create_presentation.html.heex new file mode 100644 index 0000000..9992a65 --- /dev/null +++ b/lib/t_t_admin_u_i/live/create_presentation.html.heex @@ -0,0 +1,16 @@ +
+
+

Create New

+
+ + <%= if @loading do %> +

Loadingโ€ฆ

+ <% else %> + <.form :let={f} for={@changeset} phx-change="change" phx-submit="submit"> + <%= label(f, :name) %> + <%= text_input(f, :name, placeholder: "The Joys of Elixir") %> + + <%= submit("Create", disabled: not @changeset.valid?) %> + + <% end %> +
diff --git a/lib/t_t_admin_u_i/live/main.ex b/lib/t_t_admin_u_i/live/main.ex new file mode 100644 index 0000000..36b8df1 --- /dev/null +++ b/lib/t_t_admin_u_i/live/main.ex @@ -0,0 +1,16 @@ +defmodule TTAdminUI.Live.Main do + use Phoenix.LiveView, layout: TTAdminUI.LayoutView.config() + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTAdminUI.Router.Helpers, as: Routes + + alias TTAdmin.Storage.DB + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + presentations = DB.get_presentations() + + {:ok, assign(socket, presentations: presentations, page_title: "Presentations")} + end +end diff --git a/lib/t_t_admin_u_i/live/main.html.heex b/lib/t_t_admin_u_i/live/main.html.heex new file mode 100644 index 0000000..95e90fb --- /dev/null +++ b/lib/t_t_admin_u_i/live/main.html.heex @@ -0,0 +1,23 @@ +
+
+

Presentations

+
+ + + +
+ <.link navigate={Routes.live_path(@socket, TTAdminUI.Live.CreatePresentation)} class="button"> + Create New + +
+
diff --git a/lib/t_t_admin_u_i/live/view_presentation.ex b/lib/t_t_admin_u_i/live/view_presentation.ex new file mode 100644 index 0000000..55f9136 --- /dev/null +++ b/lib/t_t_admin_u_i/live/view_presentation.ex @@ -0,0 +1,165 @@ +defmodule TTAdminUI.Live.ViewPresentation do + use Phoenix.LiveView, layout: TTAdminUI.LayoutView.config() + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTAdminUI.Router.Helpers, as: Routes + alias Phoenix.LiveView.JS + + alias TTAdmin.Storage.DB + alias TTAdmin.Presentation.PubSub + alias TTAdmin.Presentation.Server + alias TTAdmin.Schemas.Presentation + + @impl Phoenix.LiveView + def mount(params, _session, socket) do + server_pid = Server.get_server!(Map.get(params, "id")) + presentation = Server.get(server_pid) + + questions = DB.get_questions(presentation) |> sort() + + if connected?(socket) do + PubSub.listen_presentation(presentation) + PubSub.listen_reactions(presentation) + PubSub.listen_questions(presentation) + Server.set_admin(server_pid, self()) + end + + {:ok, + assign(socket, + page_title: presentation.name, + presentation: presentation, + server_pid: server_pid, + reactions: [], + questions: questions, + waiting_screenshot: [] + ), temporary_assigns: [reactions: []]} + end + + @impl Phoenix.LiveView + def handle_event(event, params, socket) + + def handle_event("got-screenshot-perm", %{"has" => has}, socket) do + changeset = + Presentation.update_screenshot(socket.assigns.presentation, %{can_screenshot: has}) + + socket = + case Server.update(socket.assigns.server_pid, changeset) do + {:ok, presentation} -> assign(socket, presentation: presentation) + {:error, _error} -> socket + end + + {:noreply, socket} + end + + def handle_event("took-screenshot", %{"data" => data}, socket) do + socket = + if socket.assigns.waiting_screenshot != [] do + for waiter <- socket.assigns.waiting_screenshot do + GenServer.reply(waiter, data) + end + + assign(socket, waiting_screenshot: []) + else + socket + end + + {:noreply, socket} + end + + def handle_event("answer-question", %{"value" => id}, socket) do + questions = + update_question( + socket.assigns.presentation, + socket.assigns.questions, + id, + &%{&1 | answered: true} + ) + + {:noreply, assign(socket, questions: questions)} + end + + def handle_event("unanswer-question", %{"value" => id}, socket) do + questions = + update_question( + socket.assigns.presentation, + socket.assigns.questions, + id, + &%{&1 | answered: false} + ) + + {:noreply, assign(socket, questions: questions)} + end + + def handle_event("remove-question", %{"value" => id}, socket) do + DB.remove_question(socket.assigns.presentation, id) + questions = Enum.reject(socket.assigns.questions, &(&1.id == id)) + {:noreply, assign(socket, questions: questions)} + end + + @impl Phoenix.LiveView + def handle_call(msg, from, socket) + + def handle_call(:take_screenshot, from, socket) do + socket = + if socket.assigns.waiting_screenshot == [] do + socket |> push_event("take-screenshot", %{}) |> assign(waiting_screenshot: [from]) + else + assign(socket, waiting_screenshot: [from | socket.assigns.waiting_screenshot]) + end + + {:noreply, socket} + end + + @impl Phoenix.LiveView + def handle_info(msg, socket) + + def handle_info({:update_presentation, presentation}, socket) do + {:noreply, assign(socket, presentation: presentation)} + end + + def handle_info({:new_reaction, _id, reaction}, socket) do + {:noreply, assign(socket, reactions: [reaction])} + end + + def handle_info({:new_question, _id, question}, socket) do + {:noreply, assign(socket, questions: socket.assigns.questions ++ [question])} + end + + @impl Phoenix.LiveView + def terminate(_reason, socket) do + changeset = + Presentation.update_screenshot(socket.assigns.presentation, %{can_screenshot: false}) + + Server.update(socket.assigns.server_pid, changeset) + Server.set_admin(socket.assigns.server_pid, nil) + end + + @spec update_question( + Presentation.t(), + [TTAdmin.Schemas.Question.t()], + TTAdmin.Schemas.Question.id(), + (TTAdmin.Schemas.Question.t() -> TTAdmin.Schemas.Question.t()) + ) :: [TTAdmin.Schemas.Question.t()] + defp update_question(presentation, questions, id, op) do + question = Enum.find(questions, &(&1.id == id)) + + if question != nil do + question = op.(question) + DB.put_question(presentation, question) + questions |> Enum.reject(&(&1.id == id)) |> then(&[question | &1]) |> sort() + else + questions + end + end + + @spec reaction_id(TTAdmin.Schemas.Reaction.t()) :: String.t() + defp reaction_id(reaction) do + "emoji-reaction-#{reaction.id}" + end + + @spec sort([TTAdmin.Schemas.Question.t()]) :: [TTAdmin.Schemas.Question.t()] + defp sort(questions) do + Enum.sort_by(questions, & &1.at, {:asc, DateTime}) + end +end diff --git a/lib/t_t_admin_u_i/live/view_presentation.html.heex b/lib/t_t_admin_u_i/live/view_presentation.html.heex new file mode 100644 index 0000000..cb97a46 --- /dev/null +++ b/lib/t_t_admin_u_i/live/view_presentation.html.heex @@ -0,0 +1,38 @@ +
+
+

<%= @presentation.name %>

+
+ + + +
+ +
+ +

Permissions

+ <%= label() do %> + + Allow screenshots + <% end %> + +
+ +

Questions

+
+ <%= for q <- @questions do %> + + <% end %> + + <%= if @questions == [] do %> +

No one has asked anything yet.

+ <% end %> +
+ +
+ <%= for reaction <- @reactions do %> + + <%= reaction.emoji %> + + <% end %> +
+
diff --git a/lib/t_t_admin_u_i/router.ex b/lib/t_t_admin_u_i/router.ex new file mode 100644 index 0000000..4522597 --- /dev/null +++ b/lib/t_t_admin_u_i/router.ex @@ -0,0 +1,22 @@ +defmodule TTAdminUI.Router do + use Phoenix.Router + import Phoenix.LiveView.Router + + pipeline :browser do + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, {TTAdminUI.LayoutView, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(TTUICommon.FLoCPlug) + end + + scope "/" do + pipe_through(:browser) + + live("/", TTAdminUI.Live.Main) + live("/create", TTAdminUI.Live.CreatePresentation) + live("/presentation/:id", TTAdminUI.Live.ViewPresentation) + end +end diff --git a/lib/t_t_admin_u_i/templates/layout/live.html.heex b/lib/t_t_admin_u_i/templates/layout/live.html.heex new file mode 100644 index 0000000..477ca4d --- /dev/null +++ b/lib/t_t_admin_u_i/templates/layout/live.html.heex @@ -0,0 +1,17 @@ +
+
+ +

TalkTool

+
+
+ + + + + + <%= @inner_content %> +
diff --git a/lib/t_t_admin_u_i/templates/layout/root.html.heex b/lib/t_t_admin_u_i/templates/layout/root.html.heex new file mode 100644 index 0000000..bdab77c --- /dev/null +++ b/lib/t_t_admin_u_i/templates/layout/root.html.heex @@ -0,0 +1,27 @@ + + + + + + + <%= Phoenix.HTML.Tag.csrf_meta_tag() %> + + <%= assigns[:page_title] %> + + + + + + <%= @inner_content %> + + diff --git a/lib/t_t_client_u_i/endpoint.ex b/lib/t_t_client_u_i/endpoint.ex new file mode 100644 index 0000000..81522b8 --- /dev/null +++ b/lib/t_t_client_u_i/endpoint.ex @@ -0,0 +1,46 @@ +defmodule TTClientUI.Endpoint do + use Phoenix.Endpoint, otp_app: :talk_tool + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_talk_tool_client_key", + signing_salt: "thatssorad" + ] + + socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug(Plug.Static, + at: "/static/", + from: "priv/static", + gzip: false, + only: ~w(admin client common) + ) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) + plug(Phoenix.LiveReloader) + plug(Phoenix.CodeReloader) + end + + plug(Plug.RequestId) + + plug(Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + plug(TTClientUI.Router) +end diff --git a/lib/t_t_client_u_i/error_view.ex b/lib/t_t_client_u_i/error_view.ex new file mode 100644 index 0000000..9345c15 --- /dev/null +++ b/lib/t_t_client_u_i/error_view.ex @@ -0,0 +1,5 @@ +defmodule TTClientUI.ErrorView do + use Phoenix.View, root: Path.expand("#{__DIR__}/templates"), namespace: TTClientUI + + alias TTClientUI.Router.Helpers, as: Routes +end diff --git a/lib/t_t_client_u_i/layout_view.ex b/lib/t_t_client_u_i/layout_view.ex new file mode 100644 index 0000000..e72c95e --- /dev/null +++ b/lib/t_t_client_u_i/layout_view.ex @@ -0,0 +1,7 @@ +defmodule TTClientUI.LayoutView do + use Phoenix.View, root: Path.expand("#{__DIR__}/templates"), namespace: TTClientUI + + alias TTClientUI.Router.Helpers, as: Routes + + def config(), do: {__MODULE__, "live.html"} +end diff --git a/lib/t_t_client_u_i/live/ask_question.ex b/lib/t_t_client_u_i/live/ask_question.ex new file mode 100644 index 0000000..1deac0c --- /dev/null +++ b/lib/t_t_client_u_i/live/ask_question.ex @@ -0,0 +1,84 @@ +defmodule TTClientUI.Live.AskQuestion do + use Phoenix.LiveView, layout: TTClientUI.LayoutView.config() + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTClientUI.Router.Helpers, as: Routes + + alias TTAdmin.Schemas.Question + alias TTAdmin.Storage.DB + alias TTAdmin.Presentation.Server + alias TTAdmin.Presentation.PubSub + + @impl Phoenix.LiveView + def mount(params, _session, socket) do + server_pid = Server.get_server!(Map.get(params, "id")) + presentation = Server.get(server_pid) + admin_pid = Server.get_admin(server_pid) + changeset = Question.create_changeset(presentation, %{}) + + if connected?(socket) do + PubSub.listen_presentation(presentation) + PubSub.listen_admin_pid(presentation) + end + + {:ok, + assign(socket, + page_title: presentation.name, + presentation: presentation, + admin_pid: admin_pid, + changeset: changeset, + picture: nil + )} + end + + @impl Phoenix.LiveView + def handle_event(evt, params, socket) + + def handle_event("change", %{"question" => question}, socket) do + changeset = Question.create_changeset(socket.assigns.presentation, question) + + {:noreply, assign(socket, changeset: changeset)} + end + + def handle_event("submit", %{"question" => question}, socket) do + changeset = + Question.create_changeset( + socket.assigns.presentation, + Map.put(question, "picture", socket.assigns.picture) + ) + + case DB.create_question(changeset) do + {:ok, question} -> + TTAdmin.Presentation.PubSub.publish_question(question) + + {:noreply, + push_navigate(socket, + to: + Routes.live_path(socket, TTClientUI.Live.ViewPresentation, question.presentation_id) + )} + + {:error, changeset} -> + {:noreply, assign(socket, changeset: changeset, submit_error: true)} + end + end + + def handle_event("select-screenshot", %{"value" => data}, socket) do + {:noreply, assign(socket, picture: data)} + end + + def handle_event("remove-screenshot", _params, socket) do + {:noreply, assign(socket, picture: nil)} + end + + @impl Phoenix.LiveView + def handle_info(msg, socket) + + def handle_info({:update_presentation, presentation}, socket) do + {:noreply, assign(socket, presentation: presentation)} + end + + def handle_info({:new_admin_pid, pid}, socket) do + {:noreply, assign(socket, admin_pid: pid)} + end +end diff --git a/lib/t_t_client_u_i/live/ask_question.html.heex b/lib/t_t_client_u_i/live/ask_question.html.heex new file mode 100644 index 0000000..936fe68 --- /dev/null +++ b/lib/t_t_client_u_i/live/ask_question.html.heex @@ -0,0 +1,36 @@ +
+
+

Ask Question

+
+ + <.form :let={f} for={@changeset} phx-change="change" phx-submit="submit"> + <%= label(f, :text) %> + <%= text_input(f, :text) %> + + <%= submit("โœ“", disabled: not @changeset.valid?) %> + <.link navigate={ + Routes.live_path(@socket, TTClientUI.Live.ViewPresentation, @presentation.id) + }> + Back + + +

Attach Screenshot

+ <%= if @picture do %> +

Selected picture:

+ + + <% else %> + <%= if @presentation.can_screenshot and @admin_pid != nil do %> + <.live_component + id="screenshot-component" + module={TTClientUI.Live.Components.Screenshot} + presentation={@presentation} + admin_pid={@admin_pid} + mode={:attach} + /> + <% else %> +

Taking a screenshot is not available at this moment.

+ <% end %> + <% end %> + +
diff --git a/lib/t_t_client_u_i/live/components/emoji_picker/data.ex b/lib/t_t_client_u_i/live/components/emoji_picker/data.ex new file mode 100644 index 0000000..46d51fe --- /dev/null +++ b/lib/t_t_client_u_i/live/components/emoji_picker/data.ex @@ -0,0 +1,1942 @@ +defmodule TTClientUI.Live.Components.EmojiPicker.Data do + @categories [ + :favourites, + :people, + :nature, + :food, + :activity, + :travel, + :objects, + :symbols, + :flags + ] + + @emojis %{ + favourites: [ + {"Thumbs Up", "๐Ÿ‘"}, + {"Grinning Face with Smiling Eyes", "๐Ÿ˜„"}, + {"Red Question Mark", "โ“"}, + {"Clapping Hands", "๐Ÿ‘"} + ], + people: [ + {"Grinning Face", "๐Ÿ˜€"}, + {"Grinning Face with Big Eyes", "๐Ÿ˜ƒ"}, + {"Grinning Face with Smiling Eyes", "๐Ÿ˜„"}, + {"Beaming Face with Smiling Eyes", "๐Ÿ˜"}, + {"Grinning Squinting Face", "๐Ÿ˜†"}, + {"Grinning Face with Sweat", "๐Ÿ˜…"}, + {"Rolling on the Floor Laughing", "๐Ÿคฃ"}, + {"Face with Tears of Joy", "๐Ÿ˜‚"}, + {"Slightly Smiling Face", "๐Ÿ™‚"}, + {"Upside-Down Face", "๐Ÿ™ƒ"}, + {"Melting Face", "๐Ÿซ "}, + {"Winking Face", "๐Ÿ˜‰"}, + {"Smiling Face with Smiling Eyes", "๐Ÿ˜Š"}, + {"Smiling Face with Halo", "๐Ÿ˜‡"}, + {"Smiling Face with Hearts", "๐Ÿฅฐ"}, + {"Smiling Face with Heart-Eyes", "๐Ÿ˜"}, + {"Star-Struck", "๐Ÿคฉ"}, + {"Face Blowing a Kiss", "๐Ÿ˜˜"}, + {"Kissing Face", "๐Ÿ˜—"}, + {"Smiling Face", "โ˜บ๏ธ"}, + {"Kissing Face with Closed Eyes", "๐Ÿ˜š"}, + {"Kissing Face with Smiling Eyes", "๐Ÿ˜™"}, + {"Smiling Face with Tear", "๐Ÿฅฒ"}, + {"Face Savoring Food", "๐Ÿ˜‹"}, + {"Face with Tongue", "๐Ÿ˜›"}, + {"Winking Face with Tongue", "๐Ÿ˜œ"}, + {"Zany Face", "๐Ÿคช"}, + {"Squinting Face with Tongue", "๐Ÿ˜"}, + {"Money-Mouth Face", "๐Ÿค‘"}, + {"Smiling Face with Open Hands", "๐Ÿค—"}, + {"Face with Hand Over Mouth", "๐Ÿคญ"}, + {"Face with Open Eyes and Hand Over Mouth", "๐Ÿซข"}, + {"Face with Peeking Eye", "๐Ÿซฃ"}, + {"Shushing Face", "๐Ÿคซ"}, + {"Thinking Face", "๐Ÿค”"}, + {"Saluting Face", "๐Ÿซก"}, + {"Zipper-Mouth Face", "๐Ÿค"}, + {"Face with Raised Eyebrow", "๐Ÿคจ"}, + {"Neutral Face", "๐Ÿ˜"}, + {"Expressionless Face", "๐Ÿ˜‘"}, + {"Face Without Mouth", "๐Ÿ˜ถ"}, + {"Dotted Line Face", "๐Ÿซฅ"}, + {"Face in Clouds", "๐Ÿ˜ถโ€๐ŸŒซ๏ธ"}, + {"Smirking Face", "๐Ÿ˜"}, + {"Unamused Face", "๐Ÿ˜’"}, + {"Face with Rolling Eyes", "๐Ÿ™„"}, + {"Grimacing Face", "๐Ÿ˜ฌ"}, + {"Face Exhaling", "๐Ÿ˜ฎโ€๐Ÿ’จ"}, + {"Lying Face", "๐Ÿคฅ"}, + {"Relieved Face", "๐Ÿ˜Œ"}, + {"Pensive Face", "๐Ÿ˜”"}, + {"Sleepy Face", "๐Ÿ˜ช"}, + {"Drooling Face", "๐Ÿคค"}, + {"Sleeping Face", "๐Ÿ˜ด"}, + {"Face with Medical Mask", "๐Ÿ˜ท"}, + {"Face with Thermometer", "๐Ÿค’"}, + {"Face with Head-Bandage", "๐Ÿค•"}, + {"Nauseated Face", "๐Ÿคข"}, + {"Face Vomiting", "๐Ÿคฎ"}, + {"Sneezing Face", "๐Ÿคง"}, + {"Hot Face", "๐Ÿฅต"}, + {"Cold Face", "๐Ÿฅถ"}, + {"Woozy Face", "๐Ÿฅด"}, + {"Face with Crossed-Out Eyes", "๐Ÿ˜ต"}, + {"Face with Spiral Eyes", "๐Ÿ˜ตโ€๐Ÿ’ซ"}, + {"Exploding Head", "๐Ÿคฏ"}, + {"Cowboy Hat Face", "๐Ÿค "}, + {"Partying Face", "๐Ÿฅณ"}, + {"Disguised Face", "๐Ÿฅธ"}, + {"Smiling Face with Sunglasses", "๐Ÿ˜Ž"}, + {"Nerd Face", "๐Ÿค“"}, + {"Face with Monocle", "๐Ÿง"}, + {"Confused Face", "๐Ÿ˜•"}, + {"Face with Diagonal Mouth", "๐Ÿซค"}, + {"Worried Face", "๐Ÿ˜Ÿ"}, + {"Slightly Frowning Face", "๐Ÿ™"}, + {"Frowning Face", "โ˜น๏ธ"}, + {"Face with Open Mouth", "๐Ÿ˜ฎ"}, + {"Hushed Face", "๐Ÿ˜ฏ"}, + {"Astonished Face", "๐Ÿ˜ฒ"}, + {"Flushed Face", "๐Ÿ˜ณ"}, + {"Pleading Face", "๐Ÿฅบ"}, + {"Face Holding Back Tears", "๐Ÿฅน"}, + {"Frowning Face with Open Mouth", "๐Ÿ˜ฆ"}, + {"Anguished Face", "๐Ÿ˜ง"}, + {"Fearful Face", "๐Ÿ˜จ"}, + {"Anxious Face with Sweat", "๐Ÿ˜ฐ"}, + {"Sad but Relieved Face", "๐Ÿ˜ฅ"}, + {"Crying Face", "๐Ÿ˜ข"}, + {"Loudly Crying Face", "๐Ÿ˜ญ"}, + {"Face Screaming in Fear", "๐Ÿ˜ฑ"}, + {"Confounded Face", "๐Ÿ˜–"}, + {"Persevering Face", "๐Ÿ˜ฃ"}, + {"Disappointed Face", "๐Ÿ˜ž"}, + {"Downcast Face with Sweat", "๐Ÿ˜“"}, + {"Weary Face", "๐Ÿ˜ฉ"}, + {"Tired Face", "๐Ÿ˜ซ"}, + {"Yawning Face", "๐Ÿฅฑ"}, + {"Face with Steam From Nose", "๐Ÿ˜ค"}, + {"Enraged Face", "๐Ÿ˜ก"}, + {"Angry Face", "๐Ÿ˜ "}, + {"Face with Symbols on Mouth", "๐Ÿคฌ"}, + {"Smiling Face with Horns", "๐Ÿ˜ˆ"}, + {"Angry Face with Horns", "๐Ÿ‘ฟ"}, + {"Skull", "๐Ÿ’€"}, + {"Skull and Crossbones", "โ˜ ๏ธ"}, + {"Pile of Poo", "๐Ÿ’ฉ"}, + {"Clown Face", "๐Ÿคก"}, + {"Ogre", "๐Ÿ‘น"}, + {"Goblin", "๐Ÿ‘บ"}, + {"Ghost", "๐Ÿ‘ป"}, + {"Alien", "๐Ÿ‘ฝ"}, + {"Alien Monster", "๐Ÿ‘พ"}, + {"Robot", "๐Ÿค–"}, + {"Grinning Cat", "๐Ÿ˜บ"}, + {"Grinning Cat with Smiling Eyes", "๐Ÿ˜ธ"}, + {"Cat with Tears of Joy", "๐Ÿ˜น"}, + {"Smiling Cat with Heart-Eyes", "๐Ÿ˜ป"}, + {"Cat with Wry Smile", "๐Ÿ˜ผ"}, + {"Kissing Cat", "๐Ÿ˜ฝ"}, + {"Weary Cat", "๐Ÿ™€"}, + {"Crying Cat", "๐Ÿ˜ฟ"}, + {"Pouting Cat", "๐Ÿ˜พ"}, + {"Kiss Mark", "๐Ÿ’‹"}, + {"Waving Hand", "๐Ÿ‘‹"}, + {"Raised Back of Hand", "๐Ÿคš"}, + {"Hand with Fingers Splayed", "๐Ÿ–๏ธ"}, + {"Raised Hand", "โœ‹"}, + {"Vulcan Salute", "๐Ÿ––"}, + {"Rightwards Hand", "๐Ÿซฑ"}, + {"Leftwards Hand", "๐Ÿซฒ"}, + {"Palm Down Hand", "๐Ÿซณ"}, + {"Palm Up Hand", "๐Ÿซด"}, + {"OK Hand", "๐Ÿ‘Œ"}, + {"Pinched Fingers", "๐ŸคŒ"}, + {"Pinching Hand", "๐Ÿค"}, + {"Victory Hand", "โœŒ๏ธ"}, + {"Crossed Fingers", "๐Ÿคž"}, + {"Hand with Index Finger and Thumb Crossed", "๐Ÿซฐ"}, + {"Love-You Gesture", "๐ŸคŸ"}, + {"Sign of the Horns", "๐Ÿค˜"}, + {"Call Me Hand", "๐Ÿค™"}, + {"Backhand Index Pointing Left", "๐Ÿ‘ˆ"}, + {"Backhand Index Pointing Right", "๐Ÿ‘‰"}, + {"Backhand Index Pointing Up", "๐Ÿ‘†"}, + {"Middle Finger", "๐Ÿ–•"}, + {"Backhand Index Pointing Down", "๐Ÿ‘‡"}, + {"Index Pointing Up", "โ˜๏ธ"}, + {"Index Pointing at the Viewer", "๐Ÿซต"}, + {"Thumbs Up", "๐Ÿ‘"}, + {"Thumbs Down", "๐Ÿ‘Ž"}, + {"Raised Fist", "โœŠ"}, + {"Oncoming Fist", "๐Ÿ‘Š"}, + {"Left-Facing Fist", "๐Ÿค›"}, + {"Right-Facing Fist", "๐Ÿคœ"}, + {"Clapping Hands", "๐Ÿ‘"}, + {"Raising Hands", "๐Ÿ™Œ"}, + {"Heart Hands", "๐Ÿซถ"}, + {"Open Hands", "๐Ÿ‘"}, + {"Palms Up Together", "๐Ÿคฒ"}, + {"Handshake", "๐Ÿค"}, + {"Folded Hands", "๐Ÿ™"}, + {"Writing Hand", "โœ๏ธ"}, + {"Nail Polish", "๐Ÿ’…"}, + {"Selfie", "๐Ÿคณ"}, + {"Flexed Biceps", "๐Ÿ’ช"}, + {"Mechanical Arm", "๐Ÿฆพ"}, + {"Mechanical Leg", "๐Ÿฆฟ"}, + {"Leg", "๐Ÿฆต"}, + {"Foot", "๐Ÿฆถ"}, + {"Ear", "๐Ÿ‘‚"}, + {"Ear with Hearing Aid", "๐Ÿฆป"}, + {"Nose", "๐Ÿ‘ƒ"}, + {"Brain", "๐Ÿง "}, + {"Anatomical Heart", "๐Ÿซ€"}, + {"Lungs", "๐Ÿซ"}, + {"Tooth", "๐Ÿฆท"}, + {"Bone", "๐Ÿฆด"}, + {"Eyes", "๐Ÿ‘€"}, + {"Eye", "๐Ÿ‘๏ธ"}, + {"Tongue", "๐Ÿ‘…"}, + {"Mouth", "๐Ÿ‘„"}, + {"Biting Lip", "๐Ÿซฆ"}, + {"Baby", "๐Ÿ‘ถ"}, + {"Child", "๐Ÿง’"}, + {"Boy", "๐Ÿ‘ฆ"}, + {"Girl", "๐Ÿ‘ง"}, + {"Person", "๐Ÿง‘"}, + {"Person: Blond Hair", "๐Ÿ‘ฑ"}, + {"Man", "๐Ÿ‘จ"}, + {"Person: Beard", "๐Ÿง”"}, + {"Man: Red Hair", "๐Ÿ‘จโ€๐Ÿฆฐ"}, + {"Man: Curly Hair", "๐Ÿ‘จโ€๐Ÿฆฑ"}, + {"Man: White Hair", "๐Ÿ‘จโ€๐Ÿฆณ"}, + {"Man: Bald", "๐Ÿ‘จโ€๐Ÿฆฒ"}, + {"Woman", "๐Ÿ‘ฉ"}, + {"Woman: Red Hair", "๐Ÿ‘ฉโ€๐Ÿฆฐ"}, + {"Person: Red Hair", "๐Ÿง‘โ€๐Ÿฆฐ"}, + {"Woman: Curly Hair", "๐Ÿ‘ฉโ€๐Ÿฆฑ"}, + {"Person: Curly Hair", "๐Ÿง‘โ€๐Ÿฆฑ"}, + {"Woman: White Hair", "๐Ÿ‘ฉโ€๐Ÿฆณ"}, + {"Person: White Hair", "๐Ÿง‘โ€๐Ÿฆณ"}, + {"Woman: Bald", "๐Ÿ‘ฉโ€๐Ÿฆฒ"}, + {"Person: Bald", "๐Ÿง‘โ€๐Ÿฆฒ"}, + {"Woman: Blond Hair", "๐Ÿ‘ฑโ€โ™€๏ธ"}, + {"Man: Blond Hair", "๐Ÿ‘ฑโ€โ™‚๏ธ"}, + {"Older Person", "๐Ÿง“"}, + {"Old Man", "๐Ÿ‘ด"}, + {"Old Woman", "๐Ÿ‘ต"}, + {"Person Frowning", "๐Ÿ™"}, + {"Man Frowning", "๐Ÿ™โ€โ™‚๏ธ"}, + {"Woman Frowning", "๐Ÿ™โ€โ™€๏ธ"}, + {"Person Pouting", "๐Ÿ™Ž"}, + {"Man Pouting", "๐Ÿ™Žโ€โ™‚๏ธ"}, + {"Woman Pouting", "๐Ÿ™Žโ€โ™€๏ธ"}, + {"Person Gesturing No", "๐Ÿ™…"}, + {"Man Gesturing No", "๐Ÿ™…โ€โ™‚๏ธ"}, + {"Woman Gesturing No", "๐Ÿ™…โ€โ™€๏ธ"}, + {"Person Gesturing OK", "๐Ÿ™†"}, + {"Man Gesturing OK", "๐Ÿ™†โ€โ™‚๏ธ"}, + {"Woman Gesturing OK", "๐Ÿ™†โ€โ™€๏ธ"}, + {"Person Tipping Hand", "๐Ÿ’"}, + {"Man Tipping Hand", "๐Ÿ’โ€โ™‚๏ธ"}, + {"Woman Tipping Hand", "๐Ÿ’โ€โ™€๏ธ"}, + {"Person Raising Hand", "๐Ÿ™‹"}, + {"Man Raising Hand", "๐Ÿ™‹โ€โ™‚๏ธ"}, + {"Woman Raising Hand", "๐Ÿ™‹โ€โ™€๏ธ"}, + {"Deaf Person", "๐Ÿง"}, + {"Deaf Man", "๐Ÿงโ€โ™‚๏ธ"}, + {"Deaf Woman", "๐Ÿงโ€โ™€๏ธ"}, + {"Person Bowing", "๐Ÿ™‡"}, + {"Man Bowing", "๐Ÿ™‡โ€โ™‚๏ธ"}, + {"Woman Bowing", "๐Ÿ™‡โ€โ™€๏ธ"}, + {"Person Facepalming", "๐Ÿคฆ"}, + {"Man Facepalming", "๐Ÿคฆโ€โ™‚๏ธ"}, + {"Woman Facepalming", "๐Ÿคฆโ€โ™€๏ธ"}, + {"Person Shrugging", "๐Ÿคท"}, + {"Man Shrugging", "๐Ÿคทโ€โ™‚๏ธ"}, + {"Woman Shrugging", "๐Ÿคทโ€โ™€๏ธ"}, + {"Health Worker", "๐Ÿง‘โ€โš•๏ธ"}, + {"Man Health Worker", "๐Ÿ‘จโ€โš•๏ธ"}, + {"Woman Health Worker", "๐Ÿ‘ฉโ€โš•๏ธ"}, + {"Student", "๐Ÿง‘โ€๐ŸŽ“"}, + {"Man Student", "๐Ÿ‘จโ€๐ŸŽ“"}, + {"Woman Student", "๐Ÿ‘ฉโ€๐ŸŽ“"}, + {"Teacher", "๐Ÿง‘โ€๐Ÿซ"}, + {"Man Teacher", "๐Ÿ‘จโ€๐Ÿซ"}, + {"Woman Teacher", "๐Ÿ‘ฉโ€๐Ÿซ"}, + {"Judge", "๐Ÿง‘โ€โš–๏ธ"}, + {"Man Judge", "๐Ÿ‘จโ€โš–๏ธ"}, + {"Woman Judge", "๐Ÿ‘ฉโ€โš–๏ธ"}, + {"Farmer", "๐Ÿง‘โ€๐ŸŒพ"}, + {"Man Farmer", "๐Ÿ‘จโ€๐ŸŒพ"}, + {"Woman Farmer", "๐Ÿ‘ฉโ€๐ŸŒพ"}, + {"Cook", "๐Ÿง‘โ€๐Ÿณ"}, + {"Man Cook", "๐Ÿ‘จโ€๐Ÿณ"}, + {"Woman Cook", "๐Ÿ‘ฉโ€๐Ÿณ"}, + {"Mechanic", "๐Ÿง‘โ€๐Ÿ”ง"}, + {"Man Mechanic", "๐Ÿ‘จโ€๐Ÿ”ง"}, + {"Woman Mechanic", "๐Ÿ‘ฉโ€๐Ÿ”ง"}, + {"Factory Worker", "๐Ÿง‘โ€๐Ÿญ"}, + {"Man Factory Worker", "๐Ÿ‘จโ€๐Ÿญ"}, + {"Woman Factory Worker", "๐Ÿ‘ฉโ€๐Ÿญ"}, + {"Office Worker", "๐Ÿง‘โ€๐Ÿ’ผ"}, + {"Man Office Worker", "๐Ÿ‘จโ€๐Ÿ’ผ"}, + {"Woman Office Worker", "๐Ÿ‘ฉโ€๐Ÿ’ผ"}, + {"Scientist", "๐Ÿง‘โ€๐Ÿ”ฌ"}, + {"Man Scientist", "๐Ÿ‘จโ€๐Ÿ”ฌ"}, + {"Woman Scientist", "๐Ÿ‘ฉโ€๐Ÿ”ฌ"}, + {"Technologist", "๐Ÿง‘โ€๐Ÿ’ป"}, + {"Man Technologist", "๐Ÿ‘จโ€๐Ÿ’ป"}, + {"Woman Technologist", "๐Ÿ‘ฉโ€๐Ÿ’ป"}, + {"Singer", "๐Ÿง‘โ€๐ŸŽค"}, + {"Man Singer", "๐Ÿ‘จโ€๐ŸŽค"}, + {"Woman Singer", "๐Ÿ‘ฉโ€๐ŸŽค"}, + {"Artist", "๐Ÿง‘โ€๐ŸŽจ"}, + {"Man Artist", "๐Ÿ‘จโ€๐ŸŽจ"}, + {"Woman Artist", "๐Ÿ‘ฉโ€๐ŸŽจ"}, + {"Pilot", "๐Ÿง‘โ€โœˆ๏ธ"}, + {"Man Pilot", "๐Ÿ‘จโ€โœˆ๏ธ"}, + {"Woman Pilot", "๐Ÿ‘ฉโ€โœˆ๏ธ"}, + {"Astronaut", "๐Ÿง‘โ€๐Ÿš€"}, + {"Man Astronaut", "๐Ÿ‘จโ€๐Ÿš€"}, + {"Woman Astronaut", "๐Ÿ‘ฉโ€๐Ÿš€"}, + {"Firefighter", "๐Ÿง‘โ€๐Ÿš’"}, + {"Man Firefighter", "๐Ÿ‘จโ€๐Ÿš’"}, + {"Woman Firefighter", "๐Ÿ‘ฉโ€๐Ÿš’"}, + {"Police Officer", "๐Ÿ‘ฎ"}, + {"Man Police Officer", "๐Ÿ‘ฎโ€โ™‚๏ธ"}, + {"Woman Police Officer", "๐Ÿ‘ฎโ€โ™€๏ธ"}, + {"Detective", "๐Ÿ•ต๏ธ"}, + {"Man Detective", "๐Ÿ•ต๏ธโ€โ™‚๏ธ"}, + {"Woman Detective", "๐Ÿ•ต๏ธโ€โ™€๏ธ"}, + {"Guard", "๐Ÿ’‚"}, + {"Man Guard", "๐Ÿ’‚โ€โ™‚๏ธ"}, + {"Woman Guard", "๐Ÿ’‚โ€โ™€๏ธ"}, + {"Ninja", "๐Ÿฅท"}, + {"Construction Worker", "๐Ÿ‘ท"}, + {"Man Construction Worker", "๐Ÿ‘ทโ€โ™‚๏ธ"}, + {"Woman Construction Worker", "๐Ÿ‘ทโ€โ™€๏ธ"}, + {"Person with Crown", "๐Ÿซ…"}, + {"Prince", "๐Ÿคด"}, + {"Princess", "๐Ÿ‘ธ"}, + {"Person Wearing Turban", "๐Ÿ‘ณ"}, + {"Man Wearing Turban", "๐Ÿ‘ณโ€โ™‚๏ธ"}, + {"Woman Wearing Turban", "๐Ÿ‘ณโ€โ™€๏ธ"}, + {"Person with Skullcap", "๐Ÿ‘ฒ"}, + {"Woman with Headscarf", "๐Ÿง•"}, + {"Person in Tuxedo", "๐Ÿคต"}, + {"Man in Tuxedo", "๐Ÿคตโ€โ™‚๏ธ"}, + {"Woman in Tuxedo", "๐Ÿคตโ€โ™€๏ธ"}, + {"Person with Veil", "๐Ÿ‘ฐ"}, + {"Man with Veil", "๐Ÿ‘ฐโ€โ™‚๏ธ"}, + {"Woman with Veil", "๐Ÿ‘ฐโ€โ™€๏ธ"}, + {"Pregnant Woman", "๐Ÿคฐ"}, + {"Pregnant Man", "๐Ÿซƒ"}, + {"Pregnant Person", "๐Ÿซ„"}, + {"Breast-Feeding", "๐Ÿคฑ"}, + {"Woman Feeding Baby", "๐Ÿ‘ฉโ€๐Ÿผ"}, + {"Man Feeding Baby", "๐Ÿ‘จโ€๐Ÿผ"}, + {"Person Feeding Baby", "๐Ÿง‘โ€๐Ÿผ"}, + {"Baby Angel", "๐Ÿ‘ผ"}, + {"Santa Claus", "๐ŸŽ…"}, + {"Mrs. Claus", "๐Ÿคถ"}, + {"Mx Claus", "๐Ÿง‘โ€๐ŸŽ„"}, + {"Superhero", "๐Ÿฆธ"}, + {"Man Superhero", "๐Ÿฆธโ€โ™‚๏ธ"}, + {"Woman Superhero", "๐Ÿฆธโ€โ™€๏ธ"}, + {"Supervillain", "๐Ÿฆน"}, + {"Man Supervillain", "๐Ÿฆนโ€โ™‚๏ธ"}, + {"Woman Supervillain", "๐Ÿฆนโ€โ™€๏ธ"}, + {"Mage", "๐Ÿง™"}, + {"Man Mage", "๐Ÿง™โ€โ™‚๏ธ"}, + {"Woman Mage", "๐Ÿง™โ€โ™€๏ธ"}, + {"Fairy", "๐Ÿงš"}, + {"Man Fairy", "๐Ÿงšโ€โ™‚๏ธ"}, + {"Woman Fairy", "๐Ÿงšโ€โ™€๏ธ"}, + {"Vampire", "๐Ÿง›"}, + {"Man Vampire", "๐Ÿง›โ€โ™‚๏ธ"}, + {"Woman Vampire", "๐Ÿง›โ€โ™€๏ธ"}, + {"Merperson", "๐Ÿงœ"}, + {"Merman", "๐Ÿงœโ€โ™‚๏ธ"}, + {"Mermaid", "๐Ÿงœโ€โ™€๏ธ"}, + {"Elf", "๐Ÿง"}, + {"Man Elf", "๐Ÿงโ€โ™‚๏ธ"}, + {"Woman Elf", "๐Ÿงโ€โ™€๏ธ"}, + {"Genie", "๐Ÿงž"}, + {"Man Genie", "๐Ÿงžโ€โ™‚๏ธ"}, + {"Woman Genie", "๐Ÿงžโ€โ™€๏ธ"}, + {"Zombie", "๐ŸงŸ"}, + {"Man Zombie", "๐ŸงŸโ€โ™‚๏ธ"}, + {"Woman Zombie", "๐ŸงŸโ€โ™€๏ธ"}, + {"Troll", "๐ŸงŒ"}, + {"Person Getting Massage", "๐Ÿ’†"}, + {"Man Getting Massage", "๐Ÿ’†โ€โ™‚๏ธ"}, + {"Woman Getting Massage", "๐Ÿ’†โ€โ™€๏ธ"}, + {"Person Getting Haircut", "๐Ÿ’‡"}, + {"Man Getting Haircut", "๐Ÿ’‡โ€โ™‚๏ธ"}, + {"Woman Getting Haircut", "๐Ÿ’‡โ€โ™€๏ธ"}, + {"Person Walking", "๐Ÿšถ"}, + {"Man Walking", "๐Ÿšถโ€โ™‚๏ธ"}, + {"Woman Walking", "๐Ÿšถโ€โ™€๏ธ"}, + {"Person Standing", "๐Ÿง"}, + {"Man Standing", "๐Ÿงโ€โ™‚๏ธ"}, + {"Woman Standing", "๐Ÿงโ€โ™€๏ธ"}, + {"Person Kneeling", "๐ŸงŽ"}, + {"Man Kneeling", "๐ŸงŽโ€โ™‚๏ธ"}, + {"Woman Kneeling", "๐ŸงŽโ€โ™€๏ธ"}, + {"Person with White Cane", "๐Ÿง‘โ€๐Ÿฆฏ"}, + {"Man with White Cane", "๐Ÿ‘จโ€๐Ÿฆฏ"}, + {"Woman with White Cane", "๐Ÿ‘ฉโ€๐Ÿฆฏ"}, + {"Person in Motorized Wheelchair", "๐Ÿง‘โ€๐Ÿฆผ"}, + {"Man in Motorized Wheelchair", "๐Ÿ‘จโ€๐Ÿฆผ"}, + {"Woman in Motorized Wheelchair", "๐Ÿ‘ฉโ€๐Ÿฆผ"}, + {"Person in Manual Wheelchair", "๐Ÿง‘โ€๐Ÿฆฝ"}, + {"Man in Manual Wheelchair", "๐Ÿ‘จโ€๐Ÿฆฝ"}, + {"Woman in Manual Wheelchair", "๐Ÿ‘ฉโ€๐Ÿฆฝ"}, + {"Person Running", "๐Ÿƒ"}, + {"Man Running", "๐Ÿƒโ€โ™‚๏ธ"}, + {"Woman Running", "๐Ÿƒโ€โ™€๏ธ"}, + {"Woman Dancing", "๐Ÿ’ƒ"}, + {"Man Dancing", "๐Ÿ•บ"}, + {"Person in Suit Levitating", "๐Ÿ•ด๏ธ"}, + {"People with Bunny Ears", "๐Ÿ‘ฏ"}, + {"Men with Bunny Ears", "๐Ÿ‘ฏโ€โ™‚๏ธ"}, + {"Women with Bunny Ears", "๐Ÿ‘ฏโ€โ™€๏ธ"}, + {"Person in Steamy Room", "๐Ÿง–"}, + {"Man in Steamy Room", "๐Ÿง–โ€โ™‚๏ธ"}, + {"Woman in Steamy Room", "๐Ÿง–โ€โ™€๏ธ"}, + {"Person in Lotus Position", "๐Ÿง˜"}, + {"People Holding Hands", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘"}, + {"Women Holding Hands", "๐Ÿ‘ญ"}, + {"Woman and Man Holding Hands", "๐Ÿ‘ซ"}, + {"Men Holding Hands", "๐Ÿ‘ฌ"}, + {"Kiss", "๐Ÿ’"}, + {"Kiss: Woman, Man", "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ"}, + {"Kiss: Man, Man", "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ"}, + {"Kiss: Woman, Woman", "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ"}, + {"Couple with Heart", "๐Ÿ’‘"}, + {"Couple with Heart: Woman, Man", "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ"}, + {"Couple with Heart: Man, Man", "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ"}, + {"Couple with Heart: Woman, Woman", "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ"}, + {"Family", "๐Ÿ‘ช"}, + {"Family: Man, Woman, Boy", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ"}, + {"Family: Man, Woman, Girl", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"}, + {"Family: Man, Woman, Girl, Boy", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"}, + {"Family: Man, Woman, Boy, Boy", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ"}, + {"Family: Man, Woman, Girl, Girl", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง"}, + {"Family: Man, Man, Boy", "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ"}, + {"Family: Man, Man, Girl", "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง"}, + {"Family: Man, Man, Girl, Boy", "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"}, + {"Family: Man, Man, Boy, Boy", "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ"}, + {"Family: Man, Man, Girl, Girl", "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง"}, + {"Family: Woman, Woman, Boy", "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ"}, + {"Family: Woman, Woman, Girl", "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"}, + {"Family: Woman, Woman, Girl, Boy", "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"}, + {"Family: Woman, Woman, Boy, Boy", "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ"}, + {"Family: Woman, Woman, Girl, Girl", "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง"}, + {"Family: Man, Boy", "๐Ÿ‘จโ€๐Ÿ‘ฆ"}, + {"Family: Man, Boy, Boy", "๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ"}, + {"Family: Man, Girl", "๐Ÿ‘จโ€๐Ÿ‘ง"}, + {"Family: Man, Girl, Boy", "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"}, + {"Family: Man, Girl, Girl", "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง"}, + {"Family: Woman, Boy", "๐Ÿ‘ฉโ€๐Ÿ‘ฆ"}, + {"Family: Woman, Boy, Boy", "๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ"}, + {"Family: Woman, Girl", "๐Ÿ‘ฉโ€๐Ÿ‘ง"}, + {"Family: Woman, Girl, Boy", "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"}, + {"Family: Woman, Girl, Girl", "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง"}, + {"Speaking Head", "๐Ÿ—ฃ๏ธ"}, + {"Bust in Silhouette", "๐Ÿ‘ค"}, + {"Busts in Silhouette", "๐Ÿ‘ฅ"}, + {"People Hugging", "๐Ÿซ‚"}, + {"Footprints", "๐Ÿ‘ฃ"}, + {"Luggage", "๐Ÿงณ"}, + {"Closed Umbrella", "๐ŸŒ‚"}, + {"Umbrella", "โ˜‚๏ธ"}, + {"Jack-O-Lantern", "๐ŸŽƒ"}, + {"Thread", "๐Ÿงต"}, + {"Yarn", "๐Ÿงถ"}, + {"Glasses", "๐Ÿ‘“"}, + {"Sunglasses", "๐Ÿ•ถ๏ธ"}, + {"Goggles", "๐Ÿฅฝ"}, + {"Lab Coat", "๐Ÿฅผ"}, + {"Safety Vest", "๐Ÿฆบ"}, + {"Necktie", "๐Ÿ‘”"}, + {"T-Shirt", "๐Ÿ‘•"}, + {"Jeans", "๐Ÿ‘–"}, + {"Scarf", "๐Ÿงฃ"}, + {"Gloves", "๐Ÿงค"}, + {"Coat", "๐Ÿงฅ"}, + {"Socks", "๐Ÿงฆ"}, + {"Dress", "๐Ÿ‘—"}, + {"Kimono", "๐Ÿ‘˜"}, + {"Sari", "๐Ÿฅป"}, + {"One-Piece Swimsuit", "๐Ÿฉฑ"}, + {"Briefs", "๐Ÿฉฒ"}, + {"Shorts", "๐Ÿฉณ"}, + {"Bikini", "๐Ÿ‘™"}, + {"Womanโ€™s Clothes", "๐Ÿ‘š"}, + {"Purse", "๐Ÿ‘›"}, + {"Handbag", "๐Ÿ‘œ"}, + {"Clutch Bag", "๐Ÿ‘"}, + {"Backpack", "๐ŸŽ’"}, + {"Thong Sandal", "๐Ÿฉด"}, + {"Manโ€™s Shoe", "๐Ÿ‘ž"}, + {"Running Shoe", "๐Ÿ‘Ÿ"}, + {"Hiking Boot", "๐Ÿฅพ"}, + {"Flat Shoe", "๐Ÿฅฟ"}, + {"High-Heeled Shoe", "๐Ÿ‘ "}, + {"Womanโ€™s Sandal", "๐Ÿ‘ก"}, + {"Ballet Shoes", "๐Ÿฉฐ"}, + {"Womanโ€™s Boot", "๐Ÿ‘ข"}, + {"Crown", "๐Ÿ‘‘"}, + {"Womanโ€™s Hat", "๐Ÿ‘’"}, + {"Top Hat", "๐ŸŽฉ"}, + {"Graduation Cap", "๐ŸŽ“"}, + {"Billed Cap", "๐Ÿงข"}, + {"Military Helmet", "๐Ÿช–"}, + {"Rescue Workerโ€™s Helmet", "โ›‘๏ธ"}, + {"Lipstick", "๐Ÿ’„"}, + {"Ring", "๐Ÿ’"}, + {"Briefcase", "๐Ÿ’ผ"}, + {"Drop of Blood", "๐Ÿฉธ"} + ], + nature: [ + {"See-No-Evil Monkey", "๐Ÿ™ˆ"}, + {"Hear-No-Evil Monkey", "๐Ÿ™‰"}, + {"Speak-No-Evil Monkey", "๐Ÿ™Š"}, + {"Collision", "๐Ÿ’ฅ"}, + {"Dizzy", "๐Ÿ’ซ"}, + {"Sweat Droplets", "๐Ÿ’ฆ"}, + {"Dashing Away", "๐Ÿ’จ"}, + {"Monkey Face", "๐Ÿต"}, + {"Monkey", "๐Ÿ’"}, + {"Gorilla", "๐Ÿฆ"}, + {"Orangutan", "๐Ÿฆง"}, + {"Dog Face", "๐Ÿถ"}, + {"Dog", "๐Ÿ•"}, + {"Guide Dog", "๐Ÿฆฎ"}, + {"Service Dog", "๐Ÿ•โ€๐Ÿฆบ"}, + {"Poodle", "๐Ÿฉ"}, + {"Wolf", "๐Ÿบ"}, + {"Fox", "๐ŸฆŠ"}, + {"Raccoon", "๐Ÿฆ"}, + {"Cat Face", "๐Ÿฑ"}, + {"Cat", "๐Ÿˆ"}, + {"Black Cat", "๐Ÿˆโ€โฌ›"}, + {"Lion", "๐Ÿฆ"}, + {"Tiger Face", "๐Ÿฏ"}, + {"Tiger", "๐Ÿ…"}, + {"Leopard", "๐Ÿ†"}, + {"Horse Face", "๐Ÿด"}, + {"Horse", "๐ŸŽ"}, + {"Unicorn", "๐Ÿฆ„"}, + {"Zebra", "๐Ÿฆ“"}, + {"Deer", "๐ŸฆŒ"}, + {"Bison", "๐Ÿฆฌ"}, + {"Cow Face", "๐Ÿฎ"}, + {"Ox", "๐Ÿ‚"}, + {"Water Buffalo", "๐Ÿƒ"}, + {"Cow", "๐Ÿ„"}, + {"Pig Face", "๐Ÿท"}, + {"Pig", "๐Ÿ–"}, + {"Boar", "๐Ÿ—"}, + {"Pig Nose", "๐Ÿฝ"}, + {"Ram", "๐Ÿ"}, + {"Ewe", "๐Ÿ‘"}, + {"Goat", "๐Ÿ"}, + {"Camel", "๐Ÿช"}, + {"Two-Hump Camel", "๐Ÿซ"}, + {"Llama", "๐Ÿฆ™"}, + {"Giraffe", "๐Ÿฆ’"}, + {"Elephant", "๐Ÿ˜"}, + {"Mammoth", "๐Ÿฆฃ"}, + {"Rhinoceros", "๐Ÿฆ"}, + {"Hippopotamus", "๐Ÿฆ›"}, + {"Mouse Face", "๐Ÿญ"}, + {"Mouse", "๐Ÿ"}, + {"Rat", "๐Ÿ€"}, + {"Hamster", "๐Ÿน"}, + {"Rabbit Face", "๐Ÿฐ"}, + {"Rabbit", "๐Ÿ‡"}, + {"Chipmunk", "๐Ÿฟ๏ธ"}, + {"Beaver", "๐Ÿฆซ"}, + {"Hedgehog", "๐Ÿฆ”"}, + {"Bat", "๐Ÿฆ‡"}, + {"Bear", "๐Ÿป"}, + {"Polar Bear", "๐Ÿปโ€โ„๏ธ"}, + {"Koala", "๐Ÿจ"}, + {"Panda", "๐Ÿผ"}, + {"Sloth", "๐Ÿฆฅ"}, + {"Otter", "๐Ÿฆฆ"}, + {"Skunk", "๐Ÿฆจ"}, + {"Kangaroo", "๐Ÿฆ˜"}, + {"Badger", "๐Ÿฆก"}, + {"Paw Prints", "๐Ÿพ"}, + {"Turkey", "๐Ÿฆƒ"}, + {"Chicken", "๐Ÿ”"}, + {"Rooster", "๐Ÿ“"}, + {"Hatching Chick", "๐Ÿฃ"}, + {"Baby Chick", "๐Ÿค"}, + {"Front-Facing Baby Chick", "๐Ÿฅ"}, + {"Bird", "๐Ÿฆ"}, + {"Penguin", "๐Ÿง"}, + {"Dove", "๐Ÿ•Š๏ธ"}, + {"Eagle", "๐Ÿฆ…"}, + {"Duck", "๐Ÿฆ†"}, + {"Swan", "๐Ÿฆข"}, + {"Owl", "๐Ÿฆ‰"}, + {"Dodo", "๐Ÿฆค"}, + {"Feather", "๐Ÿชถ"}, + {"Flamingo", "๐Ÿฆฉ"}, + {"Peacock", "๐Ÿฆš"}, + {"Parrot", "๐Ÿฆœ"}, + {"Frog", "๐Ÿธ"}, + {"Crocodile", "๐ŸŠ"}, + {"Turtle", "๐Ÿข"}, + {"Lizard", "๐ŸฆŽ"}, + {"Snake", "๐Ÿ"}, + {"Dragon Face", "๐Ÿฒ"}, + {"Dragon", "๐Ÿ‰"}, + {"Sauropod", "๐Ÿฆ•"}, + {"T-Rex", "๐Ÿฆ–"}, + {"Spouting Whale", "๐Ÿณ"}, + {"Whale", "๐Ÿ‹"}, + {"Dolphin", "๐Ÿฌ"}, + {"Seal", "๐Ÿฆญ"}, + {"Fish", "๐ŸŸ"}, + {"Tropical Fish", "๐Ÿ "}, + {"Blowfish", "๐Ÿก"}, + {"Shark", "๐Ÿฆˆ"}, + {"Octopus", "๐Ÿ™"}, + {"Spiral Shell", "๐Ÿš"}, + {"Coral", "๐Ÿชธ"}, + {"Snail", "๐ŸŒ"}, + {"Butterfly", "๐Ÿฆ‹"}, + {"Bug", "๐Ÿ›"}, + {"Ant", "๐Ÿœ"}, + {"Honeybee", "๐Ÿ"}, + {"Beetle", "๐Ÿชฒ"}, + {"Lady Beetle", "๐Ÿž"}, + {"Cricket", "๐Ÿฆ—"}, + {"Cockroach", "๐Ÿชณ"}, + {"Spider", "๐Ÿ•ท๏ธ"}, + {"Spider Web", "๐Ÿ•ธ๏ธ"}, + {"Scorpion", "๐Ÿฆ‚"}, + {"Mosquito", "๐ŸฆŸ"}, + {"Fly", "๐Ÿชฐ"}, + {"Worm", "๐Ÿชฑ"}, + {"Microbe", "๐Ÿฆ "}, + {"Bouquet", "๐Ÿ’"}, + {"Cherry Blossom", "๐ŸŒธ"}, + {"White Flower", "๐Ÿ’ฎ"}, + {"Lotus", "๐Ÿชท"}, + {"Rosette", "๐Ÿต๏ธ"}, + {"Rose", "๐ŸŒน"}, + {"Wilted Flower", "๐Ÿฅ€"}, + {"Hibiscus", "๐ŸŒบ"}, + {"Sunflower", "๐ŸŒป"}, + {"Blossom", "๐ŸŒผ"}, + {"Tulip", "๐ŸŒท"}, + {"Seedling", "๐ŸŒฑ"}, + {"Potted Plant", "๐Ÿชด"}, + {"Evergreen Tree", "๐ŸŒฒ"}, + {"Deciduous Tree", "๐ŸŒณ"}, + {"Palm Tree", "๐ŸŒด"}, + {"Cactus", "๐ŸŒต"}, + {"Sheaf of Rice", "๐ŸŒพ"}, + {"Herb", "๐ŸŒฟ"}, + {"Shamrock", "โ˜˜๏ธ"}, + {"Four Leaf Clover", "๐Ÿ€"}, + {"Maple Leaf", "๐Ÿ"}, + {"Fallen Leaf", "๐Ÿ‚"}, + {"Leaf Fluttering in Wind", "๐Ÿƒ"}, + {"Empty Nest", "๐Ÿชน"}, + {"Nest with Eggs", "๐Ÿชบ"}, + {"Mushroom", "๐Ÿ„"}, + {"Chestnut", "๐ŸŒฐ"}, + {"Crab", "๐Ÿฆ€"}, + {"Lobster", "๐Ÿฆž"}, + {"Shrimp", "๐Ÿฆ"}, + {"Squid", "๐Ÿฆ‘"}, + {"Globe Showing Europe-Africa", "๐ŸŒ"}, + {"Globe Showing Americas", "๐ŸŒŽ"}, + {"Globe Showing Asia-Australia", "๐ŸŒ"}, + {"Globe with Meridians", "๐ŸŒ"}, + {"Rock", "๐Ÿชจ"}, + {"New Moon", "๐ŸŒ‘"}, + {"Waxing Crescent Moon", "๐ŸŒ’"}, + {"First Quarter Moon", "๐ŸŒ“"}, + {"Waxing Gibbous Moon", "๐ŸŒ”"}, + {"Full Moon", "๐ŸŒ•"}, + {"Waning Gibbous Moon", "๐ŸŒ–"}, + {"Last Quarter Moon", "๐ŸŒ—"}, + {"Waning Crescent Moon", "๐ŸŒ˜"}, + {"Crescent Moon", "๐ŸŒ™"}, + {"New Moon Face", "๐ŸŒš"}, + {"First Quarter Moon Face", "๐ŸŒ›"}, + {"Last Quarter Moon Face", "๐ŸŒœ"}, + {"Sun", "โ˜€๏ธ"}, + {"Full Moon Face", "๐ŸŒ"}, + {"Sun with Face", "๐ŸŒž"}, + {"Star", "โญ"}, + {"Glowing Star", "๐ŸŒŸ"}, + {"Shooting Star", "๐ŸŒ "}, + {"Cloud", "โ˜๏ธ"}, + {"Sun Behind Cloud", "โ›…"}, + {"Cloud with Lightning and Rain", "โ›ˆ๏ธ"}, + {"Sun Behind Small Cloud", "๐ŸŒค๏ธ"}, + {"Sun Behind Large Cloud", "๐ŸŒฅ๏ธ"}, + {"Sun Behind Rain Cloud", "๐ŸŒฆ๏ธ"}, + {"Cloud with Rain", "๐ŸŒง๏ธ"}, + {"Cloud with Snow", "๐ŸŒจ๏ธ"}, + {"Cloud with Lightning", "๐ŸŒฉ๏ธ"}, + {"Tornado", "๐ŸŒช๏ธ"}, + {"Fog", "๐ŸŒซ๏ธ"}, + {"Wind Face", "๐ŸŒฌ๏ธ"}, + {"Rainbow", "๐ŸŒˆ"}, + {"Umbrella", "โ˜‚๏ธ"}, + {"Umbrella with Rain Drops", "โ˜”"}, + {"High Voltage", "โšก"}, + {"Snowflake", "โ„๏ธ"}, + {"Snowman", "โ˜ƒ๏ธ"}, + {"Snowman Without Snow", "โ›„"}, + {"Comet", "โ˜„๏ธ"}, + {"Fire", "๐Ÿ”ฅ"}, + {"Droplet", "๐Ÿ’ง"}, + {"Water Wave", "๐ŸŒŠ"}, + {"Christmas Tree", "๐ŸŽ„"}, + {"Sparkles", "โœจ"}, + {"Tanabata Tree", "๐ŸŽ‹"}, + {"Pine Decoration", "๐ŸŽ"}, + {"Bubbles", "๐Ÿซง"} + ], + food: [ + {"Grapes", "๐Ÿ‡"}, + {"Melon", "๐Ÿˆ"}, + {"Watermelon", "๐Ÿ‰"}, + {"Tangerine", "๐ŸŠ"}, + {"Lemon", "๐Ÿ‹"}, + {"Banana", "๐ŸŒ"}, + {"Pineapple", "๐Ÿ"}, + {"Mango", "๐Ÿฅญ"}, + {"Red Apple", "๐ŸŽ"}, + {"Green Apple", "๐Ÿ"}, + {"Pear", "๐Ÿ"}, + {"Peach", "๐Ÿ‘"}, + {"Cherries", "๐Ÿ’"}, + {"Strawberry", "๐Ÿ“"}, + {"Blueberries", "๐Ÿซ"}, + {"Kiwi Fruit", "๐Ÿฅ"}, + {"Tomato", "๐Ÿ…"}, + {"Olive", "๐Ÿซ’"}, + {"Coconut", "๐Ÿฅฅ"}, + {"Avocado", "๐Ÿฅ‘"}, + {"Eggplant", "๐Ÿ†"}, + {"Potato", "๐Ÿฅ”"}, + {"Carrot", "๐Ÿฅ•"}, + {"Ear of Corn", "๐ŸŒฝ"}, + {"Hot Pepper", "๐ŸŒถ๏ธ"}, + {"Bell Pepper", "๐Ÿซ‘"}, + {"Cucumber", "๐Ÿฅ’"}, + {"Leafy Green", "๐Ÿฅฌ"}, + {"Broccoli", "๐Ÿฅฆ"}, + {"Garlic", "๐Ÿง„"}, + {"Onion", "๐Ÿง…"}, + {"Mushroom", "๐Ÿ„"}, + {"Peanuts", "๐Ÿฅœ"}, + {"Beans", "๐Ÿซ˜"}, + {"Chestnut", "๐ŸŒฐ"}, + {"Bread", "๐Ÿž"}, + {"Croissant", "๐Ÿฅ"}, + {"Baguette Bread", "๐Ÿฅ–"}, + {"Flatbread", "๐Ÿซ“"}, + {"Pretzel", "๐Ÿฅจ"}, + {"Bagel", "๐Ÿฅฏ"}, + {"Pancakes", "๐Ÿฅž"}, + {"Waffle", "๐Ÿง‡"}, + {"Cheese Wedge", "๐Ÿง€"}, + {"Meat on Bone", "๐Ÿ–"}, + {"Poultry Leg", "๐Ÿ—"}, + {"Cut of Meat", "๐Ÿฅฉ"}, + {"Bacon", "๐Ÿฅ“"}, + {"Hamburger", "๐Ÿ”"}, + {"French Fries", "๐ŸŸ"}, + {"Pizza", "๐Ÿ•"}, + {"Hot Dog", "๐ŸŒญ"}, + {"Sandwich", "๐Ÿฅช"}, + {"Taco", "๐ŸŒฎ"}, + {"Burrito", "๐ŸŒฏ"}, + {"Tamale", "๐Ÿซ”"}, + {"Stuffed Flatbread", "๐Ÿฅ™"}, + {"Falafel", "๐Ÿง†"}, + {"Egg", "๐Ÿฅš"}, + {"Cooking", "๐Ÿณ"}, + {"Shallow Pan of Food", "๐Ÿฅ˜"}, + {"Pot of Food", "๐Ÿฒ"}, + {"Fondue", "๐Ÿซ•"}, + {"Bowl with Spoon", "๐Ÿฅฃ"}, + {"Green Salad", "๐Ÿฅ—"}, + {"Popcorn", "๐Ÿฟ"}, + {"Butter", "๐Ÿงˆ"}, + {"Salt", "๐Ÿง‚"}, + {"Canned Food", "๐Ÿฅซ"}, + {"Bento Box", "๐Ÿฑ"}, + {"Rice Cracker", "๐Ÿ˜"}, + {"Rice Ball", "๐Ÿ™"}, + {"Cooked Rice", "๐Ÿš"}, + {"Curry Rice", "๐Ÿ›"}, + {"Steaming Bowl", "๐Ÿœ"}, + {"Spaghetti", "๐Ÿ"}, + {"Roasted Sweet Potato", "๐Ÿ "}, + {"Oden", "๐Ÿข"}, + {"Sushi", "๐Ÿฃ"}, + {"Fried Shrimp", "๐Ÿค"}, + {"Fish Cake with Swirl", "๐Ÿฅ"}, + {"Moon Cake", "๐Ÿฅฎ"}, + {"Dango", "๐Ÿก"}, + {"Dumpling", "๐ŸฅŸ"}, + {"Fortune Cookie", "๐Ÿฅ "}, + {"Takeout Box", "๐Ÿฅก"}, + {"Oyster", "๐Ÿฆช"}, + {"Soft Ice Cream", "๐Ÿฆ"}, + {"Shaved Ice", "๐Ÿง"}, + {"Ice Cream", "๐Ÿจ"}, + {"Doughnut", "๐Ÿฉ"}, + {"Cookie", "๐Ÿช"}, + {"Birthday Cake", "๐ŸŽ‚"}, + {"Shortcake", "๐Ÿฐ"}, + {"Cupcake", "๐Ÿง"}, + {"Pie", "๐Ÿฅง"}, + {"Chocolate Bar", "๐Ÿซ"}, + {"Candy", "๐Ÿฌ"}, + {"Lollipop", "๐Ÿญ"}, + {"Custard", "๐Ÿฎ"}, + {"Honey Pot", "๐Ÿฏ"}, + {"Baby Bottle", "๐Ÿผ"}, + {"Glass of Milk", "๐Ÿฅ›"}, + {"Hot Beverage", "โ˜•"}, + {"Teapot", "๐Ÿซ–"}, + {"Teacup Without Handle", "๐Ÿต"}, + {"Sake", "๐Ÿถ"}, + {"Bottle with Popping Cork", "๐Ÿพ"}, + {"Wine Glass", "๐Ÿท"}, + {"Cocktail Glass", "๐Ÿธ"}, + {"Tropical Drink", "๐Ÿน"}, + {"Beer Mug", "๐Ÿบ"}, + {"Clinking Beer Mugs", "๐Ÿป"}, + {"Clinking Glasses", "๐Ÿฅ‚"}, + {"Tumbler Glass", "๐Ÿฅƒ"}, + {"Pouring Liquid", "๐Ÿซ—"}, + {"Cup with Straw", "๐Ÿฅค"}, + {"Bubble Tea", "๐Ÿง‹"}, + {"Beverage Box", "๐Ÿงƒ"}, + {"Mate", "๐Ÿง‰"}, + {"Ice", "๐ŸงŠ"}, + {"Chopsticks", "๐Ÿฅข"}, + {"Fork and Knife with Plate", "๐Ÿฝ๏ธ"}, + {"Fork and Knife", "๐Ÿด"}, + {"Spoon", "๐Ÿฅ„"}, + {"Jar", "๐Ÿซ™"} + ], + activity: [ + {"Person in Suit Levitating", "๐Ÿ•ด๏ธ"}, + {"Person Climbing", "๐Ÿง—"}, + {"Man Climbing", "๐Ÿง—โ€โ™‚๏ธ"}, + {"Woman Climbing", "๐Ÿง—โ€โ™€๏ธ"}, + {"Person Fencing", "๐Ÿคบ"}, + {"Horse Racing", "๐Ÿ‡"}, + {"Skier", "โ›ท๏ธ"}, + {"Snowboarder", "๐Ÿ‚"}, + {"Person Golfing", "๐ŸŒ๏ธ"}, + {"Man Golfing", "๐ŸŒ๏ธโ€โ™‚๏ธ"}, + {"Woman Golfing", "๐ŸŒ๏ธโ€โ™€๏ธ"}, + {"Person Surfing", "๐Ÿ„"}, + {"Man Surfing", "๐Ÿ„โ€โ™‚๏ธ"}, + {"Woman Surfing", "๐Ÿ„โ€โ™€๏ธ"}, + {"Person Rowing Boat", "๐Ÿšฃ"}, + {"Man Rowing Boat", "๐Ÿšฃโ€โ™‚๏ธ"}, + {"Woman Rowing Boat", "๐Ÿšฃโ€โ™€๏ธ"}, + {"Person Swimming", "๐ŸŠ"}, + {"Man Swimming", "๐ŸŠโ€โ™‚๏ธ"}, + {"Woman Swimming", "๐ŸŠโ€โ™€๏ธ"}, + {"Person Bouncing Ball", "โ›น๏ธ"}, + {"Man Bouncing Ball", "โ›น๏ธโ€โ™‚๏ธ"}, + {"Woman Bouncing Ball", "โ›น๏ธโ€โ™€๏ธ"}, + {"Person Lifting Weights", "๐Ÿ‹๏ธ"}, + {"Man Lifting Weights", "๐Ÿ‹๏ธโ€โ™‚๏ธ"}, + {"Woman Lifting Weights", "๐Ÿ‹๏ธโ€โ™€๏ธ"}, + {"Person Biking", "๐Ÿšด"}, + {"Man Biking", "๐Ÿšดโ€โ™‚๏ธ"}, + {"Woman Biking", "๐Ÿšดโ€โ™€๏ธ"}, + {"Person Mountain Biking", "๐Ÿšต"}, + {"Man Mountain Biking", "๐Ÿšตโ€โ™‚๏ธ"}, + {"Woman Mountain Biking", "๐Ÿšตโ€โ™€๏ธ"}, + {"Person Cartwheeling", "๐Ÿคธ"}, + {"Man Cartwheeling", "๐Ÿคธโ€โ™‚๏ธ"}, + {"Woman Cartwheeling", "๐Ÿคธโ€โ™€๏ธ"}, + {"People Wrestling", "๐Ÿคผ"}, + {"Men Wrestling", "๐Ÿคผโ€โ™‚๏ธ"}, + {"Women Wrestling", "๐Ÿคผโ€โ™€๏ธ"}, + {"Person Playing Water Polo", "๐Ÿคฝ"}, + {"Man Playing Water Polo", "๐Ÿคฝโ€โ™‚๏ธ"}, + {"Woman Playing Water Polo", "๐Ÿคฝโ€โ™€๏ธ"}, + {"Person Playing Handball", "๐Ÿคพ"}, + {"Man Playing Handball", "๐Ÿคพโ€โ™‚๏ธ"}, + {"Woman Playing Handball", "๐Ÿคพโ€โ™€๏ธ"}, + {"Person Juggling", "๐Ÿคน"}, + {"Man Juggling", "๐Ÿคนโ€โ™‚๏ธ"}, + {"Woman Juggling", "๐Ÿคนโ€โ™€๏ธ"}, + {"Person in Lotus Position", "๐Ÿง˜"}, + {"Man in Lotus Position", "๐Ÿง˜โ€โ™‚๏ธ"}, + {"Woman in Lotus Position", "๐Ÿง˜โ€โ™€๏ธ"}, + {"Circus Tent", "๐ŸŽช"}, + {"Skateboard", "๐Ÿ›น"}, + {"Roller Skate", "๐Ÿ›ผ"}, + {"Canoe", "๐Ÿ›ถ"}, + {"Reminder Ribbon", "๐ŸŽ—๏ธ"}, + {"Admission Tickets", "๐ŸŽŸ๏ธ"}, + {"Ticket", "๐ŸŽซ"}, + {"Military Medal", "๐ŸŽ–๏ธ"}, + {"Trophy", "๐Ÿ†"}, + {"Sports Medal", "๐Ÿ…"}, + {"1st Place Medal", "๐Ÿฅ‡"}, + {"2nd Place Medal", "๐Ÿฅˆ"}, + {"3rd Place Medal", "๐Ÿฅ‰"}, + {"Soccer Ball", "โšฝ"}, + {"Baseball", "โšพ"}, + {"Softball", "๐ŸฅŽ"}, + {"Basketball", "๐Ÿ€"}, + {"Volleyball", "๐Ÿ"}, + {"American Football", "๐Ÿˆ"}, + {"Rugby Football", "๐Ÿ‰"}, + {"Tennis", "๐ŸŽพ"}, + {"Flying Disc", "๐Ÿฅ"}, + {"Bowling", "๐ŸŽณ"}, + {"Cricket Game", "๐Ÿ"}, + {"Field Hockey", "๐Ÿ‘"}, + {"Ice Hockey", "๐Ÿ’"}, + {"Lacrosse", "๐Ÿฅ"}, + {"Ping Pong", "๐Ÿ“"}, + {"Badminton", "๐Ÿธ"}, + {"Boxing Glove", "๐ŸฅŠ"}, + {"Martial Arts Uniform", "๐Ÿฅ‹"}, + {"Goal Net", "๐Ÿฅ…"}, + {"Flag in Hole", "โ›ณ"}, + {"Ice Skate", "โ›ธ๏ธ"}, + {"Fishing Pole", "๐ŸŽฃ"}, + {"Running Shirt", "๐ŸŽฝ"}, + {"Skis", "๐ŸŽฟ"}, + {"Sled", "๐Ÿ›ท"}, + {"Curling Stone", "๐ŸฅŒ"}, + {"Bullseye", "๐ŸŽฏ"}, + {"Pool 8 Ball", "๐ŸŽฑ"}, + {"Video Game", "๐ŸŽฎ"}, + {"Slot Machine", "๐ŸŽฐ"}, + {"Game Die", "๐ŸŽฒ"}, + {"Puzzle Piece", "๐Ÿงฉ"}, + {"Mirror Ball", "๐Ÿชฉ"}, + {"Chess Pawn", "โ™Ÿ๏ธ"}, + {"Performing Arts", "๐ŸŽญ"}, + {"Artist Palette", "๐ŸŽจ"}, + {"Thread", "๐Ÿงต"}, + {"Yarn", "๐Ÿงถ"}, + {"Musical Score", "๐ŸŽผ"}, + {"Microphone", "๐ŸŽค"}, + {"Headphone", "๐ŸŽง"}, + {"Saxophone", "๐ŸŽท"}, + {"Accordion", "๐Ÿช—"}, + {"Guitar", "๐ŸŽธ"}, + {"Musical Keyboard", "๐ŸŽน"}, + {"Trumpet", "๐ŸŽบ"}, + {"Violin", "๐ŸŽป"}, + {"Drum", "๐Ÿฅ"}, + {"Long Drum", "๐Ÿช˜"}, + {"Clapper Board", "๐ŸŽฌ"}, + {"Bow and Arrow", "๐Ÿน"} + ], + travel: [ + {"Person Rowing Boat", "๐Ÿšฃ"}, + {"Map of Japan", "๐Ÿ—พ"}, + {"Snow-Capped Mountain", "๐Ÿ”๏ธ"}, + {"Mountain", "โ›ฐ๏ธ"}, + {"Volcano", "๐ŸŒ‹"}, + {"Mount Fuji", "๐Ÿ—ป"}, + {"Camping", "๐Ÿ•๏ธ"}, + {"Beach with Umbrella", "๐Ÿ–๏ธ"}, + {"Desert", "๐Ÿœ๏ธ"}, + {"Desert Island", "๐Ÿ๏ธ"}, + {"National Park", "๐Ÿž๏ธ"}, + {"Stadium", "๐ŸŸ๏ธ"}, + {"Classical Building", "๐Ÿ›๏ธ"}, + {"Building Construction", "๐Ÿ—๏ธ"}, + {"Hut", "๐Ÿ›–"}, + {"Houses", "๐Ÿ˜๏ธ"}, + {"Derelict House", "๐Ÿš๏ธ"}, + {"House", "๐Ÿ "}, + {"House with Garden", "๐Ÿก"}, + {"Office Building", "๐Ÿข"}, + {"Japanese Post Office", "๐Ÿฃ"}, + {"Post Office", "๐Ÿค"}, + {"Hospital", "๐Ÿฅ"}, + {"Bank", "๐Ÿฆ"}, + {"Hotel", "๐Ÿจ"}, + {"Love Hotel", "๐Ÿฉ"}, + {"Convenience Store", "๐Ÿช"}, + {"School", "๐Ÿซ"}, + {"Department Store", "๐Ÿฌ"}, + {"Factory", "๐Ÿญ"}, + {"Japanese Castle", "๐Ÿฏ"}, + {"Castle", "๐Ÿฐ"}, + {"Wedding", "๐Ÿ’’"}, + {"Tokyo Tower", "๐Ÿ—ผ"}, + {"Statue of Liberty", "๐Ÿ—ฝ"}, + {"Church", "โ›ช"}, + {"Mosque", "๐Ÿ•Œ"}, + {"Hindu Temple", "๐Ÿ›•"}, + {"Synagogue", "๐Ÿ•"}, + {"Shinto Shrine", "โ›ฉ๏ธ"}, + {"Kaaba", "๐Ÿ•‹"}, + {"Fountain", "โ›ฒ"}, + {"Tent", "โ›บ"}, + {"Foggy", "๐ŸŒ"}, + {"Night with Stars", "๐ŸŒƒ"}, + {"Cityscape", "๐Ÿ™๏ธ"}, + {"Sunrise Over Mountains", "๐ŸŒ„"}, + {"Sunrise", "๐ŸŒ…"}, + {"Cityscape at Dusk", "๐ŸŒ†"}, + {"Sunset", "๐ŸŒ‡"}, + {"Bridge at Night", "๐ŸŒ‰"}, + {"Carousel Horse", "๐ŸŽ "}, + {"Playground Slide", "๐Ÿ›"}, + {"Ferris Wheel", "๐ŸŽก"}, + {"Roller Coaster", "๐ŸŽข"}, + {"Locomotive", "๐Ÿš‚"}, + {"Railway Car", "๐Ÿšƒ"}, + {"High-Speed Train", "๐Ÿš„"}, + {"Bullet Train", "๐Ÿš…"}, + {"Train", "๐Ÿš†"}, + {"Metro", "๐Ÿš‡"}, + {"Light Rail", "๐Ÿšˆ"}, + {"Station", "๐Ÿš‰"}, + {"Tram", "๐ŸšŠ"}, + {"Monorail", "๐Ÿš"}, + {"Mountain Railway", "๐Ÿšž"}, + {"Tram Car", "๐Ÿš‹"}, + {"Bus", "๐ŸšŒ"}, + {"Oncoming Bus", "๐Ÿš"}, + {"Trolleybus", "๐ŸšŽ"}, + {"Minibus", "๐Ÿš"}, + {"Ambulance", "๐Ÿš‘"}, + {"Fire Engine", "๐Ÿš’"}, + {"Police Car", "๐Ÿš“"}, + {"Oncoming Police Car", "๐Ÿš”"}, + {"Taxi", "๐Ÿš•"}, + {"Oncoming Taxi", "๐Ÿš–"}, + {"Automobile", "๐Ÿš—"}, + {"Oncoming Automobile", "๐Ÿš˜"}, + {"Sport Utility Vehicle", "๐Ÿš™"}, + {"Pickup Truck", "๐Ÿ›ป"}, + {"Delivery Truck", "๐Ÿšš"}, + {"Articulated Lorry", "๐Ÿš›"}, + {"Tractor", "๐Ÿšœ"}, + {"Racing Car", "๐ŸŽ๏ธ"}, + {"Motorcycle", "๐Ÿ๏ธ"}, + {"Motor Scooter", "๐Ÿ›ต"}, + {"Auto Rickshaw", "๐Ÿ›บ"}, + {"Bicycle", "๐Ÿšฒ"}, + {"Kick Scooter", "๐Ÿ›ด"}, + {"Bus Stop", "๐Ÿš"}, + {"Motorway", "๐Ÿ›ฃ๏ธ"}, + {"Railway Track", "๐Ÿ›ค๏ธ"}, + {"Fuel Pump", "โ›ฝ"}, + {"Wheel", "๐Ÿ›ž"}, + {"Police Car Light", "๐Ÿšจ"}, + {"Horizontal Traffic Light", "๐Ÿšฅ"}, + {"Vertical Traffic Light", "๐Ÿšฆ"}, + {"Construction", "๐Ÿšง"}, + {"Anchor", "โš“"}, + {"Ring Buoy", "๐Ÿ›Ÿ"}, + {"Sailboat", "โ›ต"}, + {"Speedboat", "๐Ÿšค"}, + {"Passenger Ship", "๐Ÿ›ณ๏ธ"}, + {"Ferry", "โ›ด๏ธ"}, + {"Motor Boat", "๐Ÿ›ฅ๏ธ"}, + {"Ship", "๐Ÿšข"}, + {"Airplane", "โœˆ๏ธ"}, + {"Small Airplane", "๐Ÿ›ฉ๏ธ"}, + {"Airplane Departure", "๐Ÿ›ซ"}, + {"Airplane Arrival", "๐Ÿ›ฌ"}, + {"Parachute", "๐Ÿช‚"}, + {"Seat", "๐Ÿ’บ"}, + {"Helicopter", "๐Ÿš"}, + {"Suspension Railway", "๐ŸšŸ"}, + {"Mountain Cableway", "๐Ÿš "}, + {"Aerial Tramway", "๐Ÿšก"}, + {"Satellite", "๐Ÿ›ฐ๏ธ"}, + {"Rocket", "๐Ÿš€"}, + {"Flying Saucer", "๐Ÿ›ธ"}, + {"Ringed Planet", "๐Ÿช"}, + {"Shooting Star", "๐ŸŒ "}, + {"Milky Way", "๐ŸŒŒ"}, + {"Umbrella on Ground", "โ›ฑ๏ธ"}, + {"Fireworks", "๐ŸŽ†"}, + {"Sparkler", "๐ŸŽ‡"}, + {"Moon Viewing Ceremony", "๐ŸŽ‘"}, + {"Yen Banknote", "๐Ÿ’ด"}, + {"Dollar Banknote", "๐Ÿ’ต"}, + {"Euro Banknote", "๐Ÿ’ถ"}, + {"Pound Banknote", "๐Ÿ’ท"}, + {"Moai", "๐Ÿ—ฟ"}, + {"Passport Control", "๐Ÿ›‚"}, + {"Customs", "๐Ÿ›ƒ"}, + {"Baggage Claim", "๐Ÿ›„"}, + {"Left Luggage", "๐Ÿ›…"} + ], + objects: [ + {"Love Letter", "๐Ÿ’Œ"}, + {"Hole", "๐Ÿ•ณ๏ธ"}, + {"Bomb", "๐Ÿ’ฃ"}, + {"Person Taking Bath", "๐Ÿ›€"}, + {"Person in Bed", "๐Ÿ›Œ"}, + {"Kitchen Knife", "๐Ÿ”ช"}, + {"Amphora", "๐Ÿบ"}, + {"World Map", "๐Ÿ—บ๏ธ"}, + {"Compass", "๐Ÿงญ"}, + {"Brick", "๐Ÿงฑ"}, + {"Barber Pole", "๐Ÿ’ˆ"}, + {"Manual Wheelchair", "๐Ÿฆฝ"}, + {"Motorized Wheelchair", "๐Ÿฆผ"}, + {"Oil Drum", "๐Ÿ›ข๏ธ"}, + {"Bellhop Bell", "๐Ÿ›Ž๏ธ"}, + {"Luggage", "๐Ÿงณ"}, + {"Hourglass Done", "โŒ›"}, + {"Hourglass Not Done", "โณ"}, + {"Watch", "โŒš"}, + {"Alarm Clock", "โฐ"}, + {"Stopwatch", "โฑ๏ธ"}, + {"Timer Clock", "โฒ๏ธ"}, + {"Mantelpiece Clock", "๐Ÿ•ฐ๏ธ"}, + {"Thermometer", "๐ŸŒก๏ธ"}, + {"Umbrella on Ground", "โ›ฑ๏ธ"}, + {"Firecracker", "๐Ÿงจ"}, + {"Balloon", "๐ŸŽˆ"}, + {"Party Popper", "๐ŸŽ‰"}, + {"Confetti Ball", "๐ŸŽŠ"}, + {"Japanese Dolls", "๐ŸŽŽ"}, + {"Carp Streamer", "๐ŸŽ"}, + {"Wind Chime", "๐ŸŽ"}, + {"Red Envelope", "๐Ÿงง"}, + {"Ribbon", "๐ŸŽ€"}, + {"Wrapped Gift", "๐ŸŽ"}, + {"Diving Mask", "๐Ÿคฟ"}, + {"Yo-Yo", "๐Ÿช€"}, + {"Kite", "๐Ÿช"}, + {"Crystal Ball", "๐Ÿ”ฎ"}, + {"Magic Wand", "๐Ÿช„"}, + {"Nazar Amulet", "๐Ÿงฟ"}, + {"Hamsa", "๐Ÿชฌ"}, + {"Joystick", "๐Ÿ•น๏ธ"}, + {"Teddy Bear", "๐Ÿงธ"}, + {"Piรฑata", "๐Ÿช…"}, + {"Nesting Dolls", "๐Ÿช†"}, + {"Framed Picture", "๐Ÿ–ผ๏ธ"}, + {"Thread", "๐Ÿงต"}, + {"Sewing Needle", "๐Ÿชก"}, + {"Yarn", "๐Ÿงถ"}, + {"Knot", "๐Ÿชข"}, + {"Shopping Bags", "๐Ÿ›๏ธ"}, + {"Prayer Beads", "๐Ÿ“ฟ"}, + {"Gem Stone", "๐Ÿ’Ž"}, + {"Postal Horn", "๐Ÿ“ฏ"}, + {"Studio Microphone", "๐ŸŽ™๏ธ"}, + {"Level Slider", "๐ŸŽš๏ธ"}, + {"Control Knobs", "๐ŸŽ›๏ธ"}, + {"Radio", "๐Ÿ“ป"}, + {"Banjo", "๐Ÿช•"}, + {"Mobile Phone", "๐Ÿ“ฑ"}, + {"Mobile Phone with Arrow", "๐Ÿ“ฒ"}, + {"Telephone", "โ˜Ž๏ธ"}, + {"Telephone Receiver", "๐Ÿ“ž"}, + {"Pager", "๐Ÿ“Ÿ"}, + {"Fax Machine", "๐Ÿ“ "}, + {"Battery", "๐Ÿ”‹"}, + {"Electric Plug", "๐Ÿ”Œ"}, + {"Laptop", "๐Ÿ’ป"}, + {"Desktop Computer", "๐Ÿ–ฅ๏ธ"}, + {"Printer", "๐Ÿ–จ๏ธ"}, + {"Keyboard", "โŒจ๏ธ"}, + {"Computer Mouse", "๐Ÿ–ฑ๏ธ"}, + {"Trackball", "๐Ÿ–ฒ๏ธ"}, + {"Computer Disk", "๐Ÿ’ฝ"}, + {"Floppy Disk", "๐Ÿ’พ"}, + {"Optical Disk", "๐Ÿ’ฟ"}, + {"DVD", "๐Ÿ“€"}, + {"Abacus", "๐Ÿงฎ"}, + {"Movie Camera", "๐ŸŽฅ"}, + {"Film Frames", "๐ŸŽž๏ธ"}, + {"Film Projector", "๐Ÿ“ฝ๏ธ"}, + {"Television", "๐Ÿ“บ"}, + {"Camera", "๐Ÿ“ท"}, + {"Camera with Flash", "๐Ÿ“ธ"}, + {"Video Camera", "๐Ÿ“น"}, + {"Videocassette", "๐Ÿ“ผ"}, + {"Magnifying Glass Tilted Left", "๐Ÿ”"}, + {"Magnifying Glass Tilted Right", "๐Ÿ”Ž"}, + {"Candle", "๐Ÿ•ฏ๏ธ"}, + {"Light Bulb", "๐Ÿ’ก"}, + {"Flashlight", "๐Ÿ”ฆ"}, + {"Red Paper Lantern", "๐Ÿฎ"}, + {"Diya Lamp", "๐Ÿช”"}, + {"Notebook with Decorative Cover", "๐Ÿ“”"}, + {"Closed Book", "๐Ÿ“•"}, + {"Open Book", "๐Ÿ“–"}, + {"Green Book", "๐Ÿ“—"}, + {"Blue Book", "๐Ÿ“˜"}, + {"Orange Book", "๐Ÿ“™"}, + {"Books", "๐Ÿ“š"}, + {"Notebook", "๐Ÿ““"}, + {"Ledger", "๐Ÿ“’"}, + {"Page with Curl", "๐Ÿ“ƒ"}, + {"Scroll", "๐Ÿ“œ"}, + {"Page Facing Up", "๐Ÿ“„"}, + {"Newspaper", "๐Ÿ“ฐ"}, + {"Rolled-Up Newspaper", "๐Ÿ—ž๏ธ"}, + {"Bookmark Tabs", "๐Ÿ“‘"}, + {"Bookmark", "๐Ÿ”–"}, + {"Label", "๐Ÿท๏ธ"}, + {"Money Bag", "๐Ÿ’ฐ"}, + {"Coin", "๐Ÿช™"}, + {"Yen Banknote", "๐Ÿ’ด"}, + {"Dollar Banknote", "๐Ÿ’ต"}, + {"Euro Banknote", "๐Ÿ’ถ"}, + {"Pound Banknote", "๐Ÿ’ท"}, + {"Money with Wings", "๐Ÿ’ธ"}, + {"Credit Card", "๐Ÿ’ณ"}, + {"Receipt", "๐Ÿงพ"}, + {"Envelope", "โœ‰๏ธ"}, + {"E-Mail", "๐Ÿ“ง"}, + {"Incoming Envelope", "๐Ÿ“จ"}, + {"Envelope with Arrow", "๐Ÿ“ฉ"}, + {"Outbox Tray", "๐Ÿ“ค"}, + {"Inbox Tray", "๐Ÿ“ฅ"}, + {"Package", "๐Ÿ“ฆ"}, + {"Closed Mailbox with Raised Flag", "๐Ÿ“ซ"}, + {"Closed Mailbox with Lowered Flag", "๐Ÿ“ช"}, + {"Open Mailbox with Raised Flag", "๐Ÿ“ฌ"}, + {"Open Mailbox with Lowered Flag", "๐Ÿ“ญ"}, + {"Postbox", "๐Ÿ“ฎ"}, + {"Ballot Box with Ballot", "๐Ÿ—ณ๏ธ"}, + {"Pencil", "โœ๏ธ"}, + {"Black Nib", "โœ’๏ธ"}, + {"Fountain Pen", "๐Ÿ–‹๏ธ"}, + {"Pen", "๐Ÿ–Š๏ธ"}, + {"Paintbrush", "๐Ÿ–Œ๏ธ"}, + {"Crayon", "๐Ÿ–๏ธ"}, + {"Memo", "๐Ÿ“"}, + {"File Folder", "๐Ÿ“"}, + {"Open File Folder", "๐Ÿ“‚"}, + {"Card Index Dividers", "๐Ÿ—‚๏ธ"}, + {"Calendar", "๐Ÿ“…"}, + {"Tear-Off Calendar", "๐Ÿ“†"}, + {"Spiral Notepad", "๐Ÿ—’๏ธ"}, + {"Spiral Calendar", "๐Ÿ—“๏ธ"}, + {"Card Index", "๐Ÿ“‡"}, + {"Chart Increasing", "๐Ÿ“ˆ"}, + {"Chart Decreasing", "๐Ÿ“‰"}, + {"Bar Chart", "๐Ÿ“Š"}, + {"Clipboard", "๐Ÿ“‹"}, + {"Pushpin", "๐Ÿ“Œ"}, + {"Round Pushpin", "๐Ÿ“"}, + {"Paperclip", "๐Ÿ“Ž"}, + {"Linked Paperclips", "๐Ÿ–‡๏ธ"}, + {"Straight Ruler", "๐Ÿ“"}, + {"Triangular Ruler", "๐Ÿ“"}, + {"Scissors", "โœ‚๏ธ"}, + {"Card File Box", "๐Ÿ—ƒ๏ธ"}, + {"File Cabinet", "๐Ÿ—„๏ธ"}, + {"Wastebasket", "๐Ÿ—‘๏ธ"}, + {"Locked", "๐Ÿ”’"}, + {"Unlocked", "๐Ÿ”“"}, + {"Locked with Pen", "๐Ÿ”"}, + {"Locked with Key", "๐Ÿ”"}, + {"Key", "๐Ÿ”‘"}, + {"Old Key", "๐Ÿ—๏ธ"}, + {"Hammer", "๐Ÿ”จ"}, + {"Axe", "๐Ÿช“"}, + {"Pick", "โ›๏ธ"}, + {"Hammer and Pick", "โš’๏ธ"}, + {"Hammer and Wrench", "๐Ÿ› ๏ธ"}, + {"Dagger", "๐Ÿ—ก๏ธ"}, + {"Crossed Swords", "โš”๏ธ"}, + {"Water Pistol", "๐Ÿ”ซ"}, + {"Boomerang", "๐Ÿชƒ"}, + {"Shield", "๐Ÿ›ก๏ธ"}, + {"Carpentry Saw", "๐Ÿชš"}, + {"Wrench", "๐Ÿ”ง"}, + {"Screwdriver", "๐Ÿช›"}, + {"Nut and Bolt", "๐Ÿ”ฉ"}, + {"Gear", "โš™๏ธ"}, + {"Clamp", "๐Ÿ—œ๏ธ"}, + {"Balance Scale", "โš–๏ธ"}, + {"White Cane", "๐Ÿฆฏ"}, + {"Link", "๐Ÿ”—"}, + {"Chains", "โ›“๏ธ"}, + {"Hook", "๐Ÿช"}, + {"Toolbox", "๐Ÿงฐ"}, + {"Magnet", "๐Ÿงฒ"}, + {"Ladder", "๐Ÿชœ"}, + {"Alembic", "โš—๏ธ"}, + {"Test Tube", "๐Ÿงช"}, + {"Petri Dish", "๐Ÿงซ"}, + {"DNA", "๐Ÿงฌ"}, + {"Microscope", "๐Ÿ”ฌ"}, + {"Telescope", "๐Ÿ”ญ"}, + {"Satellite Antenna", "๐Ÿ“ก"}, + {"Syringe", "๐Ÿ’‰"}, + {"Drop of Blood", "๐Ÿฉธ"}, + {"Pill", "๐Ÿ’Š"}, + {"Adhesive Bandage", "๐Ÿฉน"}, + {"Crutch", "๐Ÿฉผ"}, + {"Stethoscope", "๐Ÿฉบ"}, + {"Door", "๐Ÿšช"}, + {"Mirror", "๐Ÿชž"}, + {"Window", "๐ŸชŸ"}, + {"Bed", "๐Ÿ›๏ธ"}, + {"Couch and Lamp", "๐Ÿ›‹๏ธ"}, + {"Chair", "๐Ÿช‘"}, + {"Toilet", "๐Ÿšฝ"}, + {"Plunger", "๐Ÿช "}, + {"Shower", "๐Ÿšฟ"}, + {"Bathtub", "๐Ÿ›"}, + {"Mouse Trap", "๐Ÿชค"}, + {"Razor", "๐Ÿช’"}, + {"Lotion Bottle", "๐Ÿงด"}, + {"Safety Pin", "๐Ÿงท"}, + {"Broom", "๐Ÿงน"}, + {"Basket", "๐Ÿงบ"}, + {"Roll of Paper", "๐Ÿงป"}, + {"Bucket", "๐Ÿชฃ"}, + {"Soap", "๐Ÿงผ"}, + {"Toothbrush", "๐Ÿชฅ"}, + {"Sponge", "๐Ÿงฝ"}, + {"Fire Extinguisher", "๐Ÿงฏ"}, + {"Shopping Cart", "๐Ÿ›’"}, + {"Cigarette", "๐Ÿšฌ"}, + {"Coffin", "โšฐ๏ธ"}, + {"Headstone", "๐Ÿชฆ"}, + {"Funeral Urn", "โšฑ๏ธ"}, + {"Moai", "๐Ÿ—ฟ"}, + {"Placard", "๐Ÿชง"}, + {"Identification Card", "๐Ÿชช"}, + {"Potable Water", "๐Ÿšฐ"} + ], + symbols: [ + {"Heart with Arrow", "๐Ÿ’˜"}, + {"Heart with Ribbon", "๐Ÿ’"}, + {"Sparkling Heart", "๐Ÿ’–"}, + {"Growing Heart", "๐Ÿ’—"}, + {"Beating Heart", "๐Ÿ’“"}, + {"Revolving Hearts", "๐Ÿ’ž"}, + {"Two Hearts", "๐Ÿ’•"}, + {"Heart Decoration", "๐Ÿ’Ÿ"}, + {"Heart Exclamation", "โฃ๏ธ"}, + {"Broken Heart", "๐Ÿ’”"}, + {"Heart on Fire", "โค๏ธโ€๐Ÿ”ฅ"}, + {"Mending Heart", "โค๏ธโ€๐Ÿฉน"}, + {"Red Heart", "โค๏ธ"}, + {"Orange Heart", "๐Ÿงก"}, + {"Yellow Heart", "๐Ÿ’›"}, + {"Green Heart", "๐Ÿ’š"}, + {"Blue Heart", "๐Ÿ’™"}, + {"Purple Heart", "๐Ÿ’œ"}, + {"Brown Heart", "๐ŸคŽ"}, + {"Black Heart", "๐Ÿ–ค"}, + {"White Heart", "๐Ÿค"}, + {"Hundred Points", "๐Ÿ’ฏ"}, + {"Anger Symbol", "๐Ÿ’ข"}, + {"Speech Balloon", "๐Ÿ’ฌ"}, + {"Eye in Speech Bubble", "๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ"}, + {"Left Speech Bubble", "๐Ÿ—จ๏ธ"}, + {"Right Anger Bubble", "๐Ÿ—ฏ๏ธ"}, + {"Thought Balloon", "๐Ÿ’ญ"}, + {"Zzz", "๐Ÿ’ค"}, + {"White Flower", "๐Ÿ’ฎ"}, + {"Hot Springs", "โ™จ๏ธ"}, + {"Barber Pole", "๐Ÿ’ˆ"}, + {"Stop Sign", "๐Ÿ›‘"}, + {"Twelve Oโ€™Clock", "๐Ÿ•›"}, + {"Twelve-Thirty", "๐Ÿ•ง"}, + {"One Oโ€™Clock", "๐Ÿ•"}, + {"One-Thirty", "๐Ÿ•œ"}, + {"Two Oโ€™Clock", "๐Ÿ•‘"}, + {"Two-Thirty", "๐Ÿ•"}, + {"Three Oโ€™Clock", "๐Ÿ•’"}, + {"Three-Thirty", "๐Ÿ•ž"}, + {"Four Oโ€™Clock", "๐Ÿ•“"}, + {"Four-Thirty", "๐Ÿ•Ÿ"}, + {"Five Oโ€™Clock", "๐Ÿ•”"}, + {"Five-Thirty", "๐Ÿ• "}, + {"Six Oโ€™Clock", "๐Ÿ••"}, + {"Six-Thirty", "๐Ÿ•ก"}, + {"Seven Oโ€™Clock", "๐Ÿ•–"}, + {"Seven-Thirty", "๐Ÿ•ข"}, + {"Eight Oโ€™Clock", "๐Ÿ•—"}, + {"Eight-Thirty", "๐Ÿ•ฃ"}, + {"Nine Oโ€™Clock", "๐Ÿ•˜"}, + {"Nine-Thirty", "๐Ÿ•ค"}, + {"Ten Oโ€™Clock", "๐Ÿ•™"}, + {"Ten-Thirty", "๐Ÿ•ฅ"}, + {"Eleven Oโ€™Clock", "๐Ÿ•š"}, + {"Eleven-Thirty", "๐Ÿ•ฆ"}, + {"Cyclone", "๐ŸŒ€"}, + {"Spade Suit", "โ™ ๏ธ"}, + {"Heart Suit", "โ™ฅ๏ธ"}, + {"Diamond Suit", "โ™ฆ๏ธ"}, + {"Club Suit", "โ™ฃ๏ธ"}, + {"Joker", "๐Ÿƒ"}, + {"Mahjong Red Dragon", "๐Ÿ€„"}, + {"Flower Playing Cards", "๐ŸŽด"}, + {"Muted Speaker", "๐Ÿ”‡"}, + {"Speaker Low Volume", "๐Ÿ”ˆ"}, + {"Speaker Medium Volume", "๐Ÿ”‰"}, + {"Speaker High Volume", "๐Ÿ”Š"}, + {"Loudspeaker", "๐Ÿ“ข"}, + {"Megaphone", "๐Ÿ“ฃ"}, + {"Postal Horn", "๐Ÿ“ฏ"}, + {"Bell", "๐Ÿ””"}, + {"Bell with Slash", "๐Ÿ”•"}, + {"Musical Note", "๐ŸŽต"}, + {"Musical Notes", "๐ŸŽถ"}, + {"Chart Increasing with Yen", "๐Ÿ’น"}, + {"Elevator", "๐Ÿ›—"}, + {"ATM Sign", "๐Ÿง"}, + {"Litter in Bin Sign", "๐Ÿšฎ"}, + {"Potable Water", "๐Ÿšฐ"}, + {"Wheelchair Symbol", "โ™ฟ"}, + {"Menโ€™s Room", "๐Ÿšน"}, + {"Womenโ€™s Room", "๐Ÿšบ"}, + {"Restroom", "๐Ÿšป"}, + {"Baby Symbol", "๐Ÿšผ"}, + {"Water Closet", "๐Ÿšพ"}, + {"Warning", "โš ๏ธ"}, + {"Children Crossing", "๐Ÿšธ"}, + {"No Entry", "โ›”"}, + {"Prohibited", "๐Ÿšซ"}, + {"No Bicycles", "๐Ÿšณ"}, + {"No Smoking", "๐Ÿšญ"}, + {"No Littering", "๐Ÿšฏ"}, + {"Non-Potable Water", "๐Ÿšฑ"}, + {"No Pedestrians", "๐Ÿšท"}, + {"No Mobile Phones", "๐Ÿ“ต"}, + {"No One Under Eighteen", "๐Ÿ”ž"}, + {"Radioactive", "โ˜ข๏ธ"}, + {"Biohazard", "โ˜ฃ๏ธ"}, + {"Up Arrow", "โฌ†๏ธ"}, + {"Up-Right Arrow", "โ†—๏ธ"}, + {"Right Arrow", "โžก๏ธ"}, + {"Down-Right Arrow", "โ†˜๏ธ"}, + {"Down Arrow", "โฌ‡๏ธ"}, + {"Down-Left Arrow", "โ†™๏ธ"}, + {"Left Arrow", "โฌ…๏ธ"}, + {"Up-Left Arrow", "โ†–๏ธ"}, + {"Up-Down Arrow", "โ†•๏ธ"}, + {"Left-Right Arrow", "โ†”๏ธ"}, + {"Right Arrow Curving Left", "โ†ฉ๏ธ"}, + {"Left Arrow Curving Right", "โ†ช๏ธ"}, + {"Right Arrow Curving Up", "โคด๏ธ"}, + {"Right Arrow Curving Down", "โคต๏ธ"}, + {"Clockwise Vertical Arrows", "๐Ÿ”ƒ"}, + {"Counterclockwise Arrows Button", "๐Ÿ”„"}, + {"Back Arrow", "๐Ÿ”™"}, + {"End Arrow", "๐Ÿ”š"}, + {"On! Arrow", "๐Ÿ”›"}, + {"Soon Arrow", "๐Ÿ”œ"}, + {"Top Arrow", "๐Ÿ”"}, + {"Place of Worship", "๐Ÿ›"}, + {"Atom Symbol", "โš›๏ธ"}, + {"Om", "๐Ÿ•‰๏ธ"}, + {"Star of David", "โœก๏ธ"}, + {"Wheel of Dharma", "โ˜ธ๏ธ"}, + {"Yin Yang", "โ˜ฏ๏ธ"}, + {"Latin Cross", "โœ๏ธ"}, + {"Orthodox Cross", "โ˜ฆ๏ธ"}, + {"Star and Crescent", "โ˜ช๏ธ"}, + {"Peace Symbol", "โ˜ฎ๏ธ"}, + {"Menorah", "๐Ÿ•Ž"}, + {"Dotted Six-Pointed Star", "๐Ÿ”ฏ"}, + {"Aries", "โ™ˆ"}, + {"Taurus", "โ™‰"}, + {"Gemini", "โ™Š"}, + {"Cancer", "โ™‹"}, + {"Leo", "โ™Œ"}, + {"Virgo", "โ™"}, + {"Libra", "โ™Ž"}, + {"Scorpio", "โ™"}, + {"Sagittarius", "โ™"}, + {"Capricorn", "โ™‘"}, + {"Aquarius", "โ™’"}, + {"Pisces", "โ™“"}, + {"Ophiuchus", "โ›Ž"}, + {"Shuffle Tracks Button", "๐Ÿ”€"}, + {"Repeat Button", "๐Ÿ”"}, + {"Repeat Single Button", "๐Ÿ”‚"}, + {"Play Button", "โ–ถ๏ธ"}, + {"Fast-Forward Button", "โฉ"}, + {"Next Track Button", "โญ๏ธ"}, + {"Play or Pause Button", "โฏ๏ธ"}, + {"Reverse Button", "โ—€๏ธ"}, + {"Fast Reverse Button", "โช"}, + {"Last Track Button", "โฎ๏ธ"}, + {"Upwards Button", "๐Ÿ”ผ"}, + {"Fast Up Button", "โซ"}, + {"Downwards Button", "๐Ÿ”ฝ"}, + {"Fast Down Button", "โฌ"}, + {"Pause Button", "โธ๏ธ"}, + {"Stop Button", "โน๏ธ"}, + {"Record Button", "โบ๏ธ"}, + {"Eject Button", "โ๏ธ"}, + {"Cinema", "๐ŸŽฆ"}, + {"Dim Button", "๐Ÿ”…"}, + {"Bright Button", "๐Ÿ”†"}, + {"Antenna Bars", "๐Ÿ“ถ"}, + {"Vibration Mode", "๐Ÿ“ณ"}, + {"Mobile Phone Off", "๐Ÿ“ด"}, + {"Female Sign", "โ™€๏ธ"}, + {"Male Sign", "โ™‚๏ธ"}, + {"Multiply", "โœ–๏ธ"}, + {"Plus", "โž•"}, + {"Minus", "โž–"}, + {"Divide", "โž—"}, + {"Heavy Equals Sign", "๐ŸŸฐ"}, + {"Infinity", "โ™พ๏ธ"}, + {"Double Exclamation Mark", "โ€ผ๏ธ"}, + {"Exclamation Question Mark", "โ‰๏ธ"}, + {"Red Question Mark", "โ“"}, + {"White Question Mark", "โ”"}, + {"White Exclamation Mark", "โ•"}, + {"Red Exclamation Mark", "โ—"}, + {"Wavy Dash", "ใ€ฐ๏ธ"}, + {"Currency Exchange", "๐Ÿ’ฑ"}, + {"Heavy Dollar Sign", "๐Ÿ’ฒ"}, + {"Medical Symbol", "โš•๏ธ"}, + {"Recycling Symbol", "โ™ป๏ธ"}, + {"Fleur-de-lis", "โšœ๏ธ"}, + {"Trident Emblem", "๐Ÿ”ฑ"}, + {"Name Badge", "๐Ÿ“›"}, + {"Japanese Symbol for Beginner", "๐Ÿ”ฐ"}, + {"Hollow Red Circle", "โญ•"}, + {"Check Mark Button", "โœ…"}, + {"Check Box with Check", "โ˜‘๏ธ"}, + {"Check Mark", "โœ”๏ธ"}, + {"Cross Mark", "โŒ"}, + {"Cross Mark Button", "โŽ"}, + {"Curly Loop", "โžฐ"}, + {"Double Curly Loop", "โžฟ"}, + {"Part Alternation Mark", "ใ€ฝ๏ธ"}, + {"Eight-Spoked Asterisk", "โœณ๏ธ"}, + {"Eight-Pointed Star", "โœด๏ธ"}, + {"Sparkle", "โ‡๏ธ"}, + {"Copyright", "ยฉ๏ธ"}, + {"Registered", "ยฎ๏ธ"}, + {"Trade Mark", "โ„ข๏ธ"}, + {"Keycap Number Sign", "#๏ธโƒฃ"}, + {"Keycap Asterisk", "*๏ธโƒฃ"}, + {"Keycap Digit Zero", "0๏ธโƒฃ"}, + {"Keycap Digit One", "1๏ธโƒฃ"}, + {"Keycap Digit Two", "2๏ธโƒฃ"}, + {"Keycap Digit Three", "3๏ธโƒฃ"}, + {"Keycap Digit Four", "4๏ธโƒฃ"}, + {"Keycap Digit Five", "5๏ธโƒฃ"}, + {"Keycap Digit Six", "6๏ธโƒฃ"}, + {"Keycap Digit Seven", "7๏ธโƒฃ"}, + {"Keycap Digit Eight", "8๏ธโƒฃ"}, + {"Keycap Digit Nine", "9๏ธโƒฃ"}, + {"Keycap: 10", "๐Ÿ”Ÿ"}, + {"Input Latin Uppercase", "๐Ÿ” "}, + {"Input Latin Lowercase", "๐Ÿ”ก"}, + {"Input Numbers", "๐Ÿ”ข"}, + {"Input Symbols", "๐Ÿ”ฃ"}, + {"Input Latin Letters", "๐Ÿ”ค"}, + {"A Button (Blood Type)", "๐Ÿ…ฐ๏ธ"}, + {"AB Button (Blood Type)", "๐Ÿ†Ž"}, + {"B Button (Blood Type)", "๐Ÿ…ฑ๏ธ"}, + {"CL Button", "๐Ÿ†‘"}, + {"Cool Button", "๐Ÿ†’"}, + {"Free Button", "๐Ÿ†“"}, + {"Information", "โ„น๏ธ"}, + {"ID Button", "๐Ÿ†”"}, + {"Circled M", "โ“‚๏ธ"}, + {"New Button", "๐Ÿ†•"}, + {"NG Button", "๐Ÿ†–"}, + {"O Button (Blood Type)", "๐Ÿ…พ๏ธ"}, + {"OK Button", "๐Ÿ†—"}, + {"P Button", "๐Ÿ…ฟ๏ธ"}, + {"SOS Button", "๐Ÿ†˜"}, + {"Up! Button", "๐Ÿ†™"}, + {"Vs Button", "๐Ÿ†š"}, + {"Japanese โ€œHereโ€ Button", "๐Ÿˆ"}, + {"Japanese โ€œService Chargeโ€ Button", "๐Ÿˆ‚๏ธ"}, + {"Japanese โ€œMonthly Amountโ€ Button", "๐Ÿˆท๏ธ"}, + {"Japanese โ€œNot Free of Chargeโ€ Button", "๐Ÿˆถ"}, + {"Japanese โ€œReservedโ€ Button", "๐Ÿˆฏ"}, + {"Japanese โ€œBargainโ€ Button", "๐Ÿ‰"}, + {"Japanese โ€œDiscountโ€ Button", "๐Ÿˆน"}, + {"Japanese โ€œFree of Chargeโ€ Button", "๐Ÿˆš"}, + {"Japanese โ€œProhibitedโ€ Button", "๐Ÿˆฒ"}, + {"Japanese โ€œAcceptableโ€ Button", "๐Ÿ‰‘"}, + {"Japanese โ€œApplicationโ€ Button", "๐Ÿˆธ"}, + {"Japanese โ€œPassing Gradeโ€ Button", "๐Ÿˆด"}, + {"Japanese โ€œVacancyโ€ Button", "๐Ÿˆณ"}, + {"Japanese โ€œCongratulationsโ€ Button", "ใŠ—๏ธ"}, + {"Japanese โ€œSecretโ€ Button", "ใŠ™๏ธ"}, + {"Japanese โ€œOpen for Businessโ€ Button", "๐Ÿˆบ"}, + {"Japanese โ€œNo Vacancyโ€ Button", "๐Ÿˆต"}, + {"Red Circle", "๐Ÿ”ด"}, + {"Orange Circle", "๐ŸŸ "}, + {"Yellow Circle", "๐ŸŸก"}, + {"Green Circle", "๐ŸŸข"}, + {"Blue Circle", "๐Ÿ”ต"}, + {"Purple Circle", "๐ŸŸฃ"}, + {"Brown Circle", "๐ŸŸค"}, + {"Black Circle", "โšซ"}, + {"White Circle", "โšช"}, + {"Red Square", "๐ŸŸฅ"}, + {"Orange Square", "๐ŸŸง"}, + {"Yellow Square", "๐ŸŸจ"}, + {"Green Square", "๐ŸŸฉ"}, + {"Blue Square", "๐ŸŸฆ"}, + {"Purple Square", "๐ŸŸช"}, + {"Brown Square", "๐ŸŸซ"}, + {"Black Large Square", "โฌ›"}, + {"White Large Square", "โฌœ"}, + {"Black Medium Square", "โ—ผ๏ธ"}, + {"White Medium Square", "โ—ป๏ธ"}, + {"Black Medium-Small Square", "โ—พ"}, + {"White Medium-Small Square", "โ—ฝ"}, + {"Black Small Square", "โ–ช๏ธ"}, + {"White Small Square", "โ–ซ๏ธ"}, + {"Large Orange Diamond", "๐Ÿ”ถ"}, + {"Large Blue Diamond", "๐Ÿ”ท"}, + {"Small Orange Diamond", "๐Ÿ”ธ"}, + {"Small Blue Diamond", "๐Ÿ”น"}, + {"Red Triangle Pointed Up", "๐Ÿ”บ"}, + {"Red Triangle Pointed Down", "๐Ÿ”ป"}, + {"Diamond with a Dot", "๐Ÿ’ "}, + {"Radio Button", "๐Ÿ”˜"}, + {"White Square Button", "๐Ÿ”ณ"}, + {"Black Square Button", "๐Ÿ”ฒ"} + ], + flags: [ + {"Chequered Flag", "๐Ÿ"}, + {"Triangular Flag", "๐Ÿšฉ"}, + {"Crossed Flags", "๐ŸŽŒ"}, + {"Black Flag", "๐Ÿด"}, + {"White Flag", "๐Ÿณ๏ธ"}, + {"Rainbow Flag", "๐Ÿณ๏ธโ€๐ŸŒˆ"}, + {"Transgender Flag", "๐Ÿณ๏ธโ€โšง๏ธ"}, + {"Pirate Flag", "๐Ÿดโ€โ˜ ๏ธ"}, + {"Flag: Ascension Island", "๐Ÿ‡ฆ๐Ÿ‡จ"}, + {"Flag: Andorra", "๐Ÿ‡ฆ๐Ÿ‡ฉ"}, + {"Flag: United Arab Emirates", "๐Ÿ‡ฆ๐Ÿ‡ช"}, + {"Flag: Afghanistan", "๐Ÿ‡ฆ๐Ÿ‡ซ"}, + {"Flag: Antigua & Barbuda", "๐Ÿ‡ฆ๐Ÿ‡ฌ"}, + {"Flag: Anguilla", "๐Ÿ‡ฆ๐Ÿ‡ฎ"}, + {"Flag: Albania", "๐Ÿ‡ฆ๐Ÿ‡ฑ"}, + {"Flag: Armenia", "๐Ÿ‡ฆ๐Ÿ‡ฒ"}, + {"Flag: Angola", "๐Ÿ‡ฆ๐Ÿ‡ด"}, + {"Flag: Antarctica", "๐Ÿ‡ฆ๐Ÿ‡ถ"}, + {"Flag: Argentina", "๐Ÿ‡ฆ๐Ÿ‡ท"}, + {"Flag: American Samoa", "๐Ÿ‡ฆ๐Ÿ‡ธ"}, + {"Flag: Austria", "๐Ÿ‡ฆ๐Ÿ‡น"}, + {"Flag: Australia", "๐Ÿ‡ฆ๐Ÿ‡บ"}, + {"Flag: Aruba", "๐Ÿ‡ฆ๐Ÿ‡ผ"}, + {"Flag: ร…land Islands", "๐Ÿ‡ฆ๐Ÿ‡ฝ"}, + {"Flag: Azerbaijan", "๐Ÿ‡ฆ๐Ÿ‡ฟ"}, + {"Flag: Bosnia & Herzegovina", "๐Ÿ‡ง๐Ÿ‡ฆ"}, + {"Flag: Barbados", "๐Ÿ‡ง๐Ÿ‡ง"}, + {"Flag: Bangladesh", "๐Ÿ‡ง๐Ÿ‡ฉ"}, + {"Flag: Belgium", "๐Ÿ‡ง๐Ÿ‡ช"}, + {"Flag: Burkina Faso", "๐Ÿ‡ง๐Ÿ‡ซ"}, + {"Flag: Bulgaria", "๐Ÿ‡ง๐Ÿ‡ฌ"}, + {"Flag: Bahrain", "๐Ÿ‡ง๐Ÿ‡ญ"}, + {"Flag: Burundi", "๐Ÿ‡ง๐Ÿ‡ฎ"}, + {"Flag: Benin", "๐Ÿ‡ง๐Ÿ‡ฏ"}, + {"Flag: St. Barthรฉlemy", "๐Ÿ‡ง๐Ÿ‡ฑ"}, + {"Flag: Bermuda", "๐Ÿ‡ง๐Ÿ‡ฒ"}, + {"Flag: Brunei", "๐Ÿ‡ง๐Ÿ‡ณ"}, + {"Flag: Bolivia", "๐Ÿ‡ง๐Ÿ‡ด"}, + {"Flag: Caribbean Netherlands", "๐Ÿ‡ง๐Ÿ‡ถ"}, + {"Flag: Brazil", "๐Ÿ‡ง๐Ÿ‡ท"}, + {"Flag: Bahamas", "๐Ÿ‡ง๐Ÿ‡ธ"}, + {"Flag: Bhutan", "๐Ÿ‡ง๐Ÿ‡น"}, + {"Flag: Bouvet Island", "๐Ÿ‡ง๐Ÿ‡ป"}, + {"Flag: Botswana", "๐Ÿ‡ง๐Ÿ‡ผ"}, + {"Flag: Belarus", "๐Ÿ‡ง๐Ÿ‡พ"}, + {"Flag: Belize", "๐Ÿ‡ง๐Ÿ‡ฟ"}, + {"Flag: Canada", "๐Ÿ‡จ๐Ÿ‡ฆ"}, + {"Flag: Cocos (Keeling) Islands", "๐Ÿ‡จ๐Ÿ‡จ"}, + {"Flag: Congo - Kinshasa", "๐Ÿ‡จ๐Ÿ‡ฉ"}, + {"Flag: Central African Republic", "๐Ÿ‡จ๐Ÿ‡ซ"}, + {"Flag: Congo - Brazzaville", "๐Ÿ‡จ๐Ÿ‡ฌ"}, + {"Flag: Switzerland", "๐Ÿ‡จ๐Ÿ‡ญ"}, + {"Flag: Cรดte dโ€™Ivoire", "๐Ÿ‡จ๐Ÿ‡ฎ"}, + {"Flag: Cook Islands", "๐Ÿ‡จ๐Ÿ‡ฐ"}, + {"Flag: Chile", "๐Ÿ‡จ๐Ÿ‡ฑ"}, + {"Flag: Cameroon", "๐Ÿ‡จ๐Ÿ‡ฒ"}, + {"Flag: China", "๐Ÿ‡จ๐Ÿ‡ณ"}, + {"Flag: Colombia", "๐Ÿ‡จ๐Ÿ‡ด"}, + {"Flag: Clipperton Island", "๐Ÿ‡จ๐Ÿ‡ต"}, + {"Flag: Costa Rica", "๐Ÿ‡จ๐Ÿ‡ท"}, + {"Flag: Cuba", "๐Ÿ‡จ๐Ÿ‡บ"}, + {"Flag: Cape Verde", "๐Ÿ‡จ๐Ÿ‡ป"}, + {"Flag: Curaรงao", "๐Ÿ‡จ๐Ÿ‡ผ"}, + {"Flag: Christmas Island", "๐Ÿ‡จ๐Ÿ‡ฝ"}, + {"Flag: Cyprus", "๐Ÿ‡จ๐Ÿ‡พ"}, + {"Flag: Czechia", "๐Ÿ‡จ๐Ÿ‡ฟ"}, + {"Flag: Germany", "๐Ÿ‡ฉ๐Ÿ‡ช"}, + {"Flag: Diego Garcia", "๐Ÿ‡ฉ๐Ÿ‡ฌ"}, + {"Flag: Djibouti", "๐Ÿ‡ฉ๐Ÿ‡ฏ"}, + {"Flag: Denmark", "๐Ÿ‡ฉ๐Ÿ‡ฐ"}, + {"Flag: Dominica", "๐Ÿ‡ฉ๐Ÿ‡ฒ"}, + {"Flag: Dominican Republic", "๐Ÿ‡ฉ๐Ÿ‡ด"}, + {"Flag: Algeria", "๐Ÿ‡ฉ๐Ÿ‡ฟ"}, + {"Flag: Ceuta & Melilla", "๐Ÿ‡ช๐Ÿ‡ฆ"}, + {"Flag: Ecuador", "๐Ÿ‡ช๐Ÿ‡จ"}, + {"Flag: Estonia", "๐Ÿ‡ช๐Ÿ‡ช"}, + {"Flag: Egypt", "๐Ÿ‡ช๐Ÿ‡ฌ"}, + {"Flag: Western Sahara", "๐Ÿ‡ช๐Ÿ‡ญ"}, + {"Flag: Eritrea", "๐Ÿ‡ช๐Ÿ‡ท"}, + {"Flag: Spain", "๐Ÿ‡ช๐Ÿ‡ธ"}, + {"Flag: Ethiopia", "๐Ÿ‡ช๐Ÿ‡น"}, + {"Flag: European Union", "๐Ÿ‡ช๐Ÿ‡บ"}, + {"Flag: Finland", "๐Ÿ‡ซ๐Ÿ‡ฎ"}, + {"Flag: Fiji", "๐Ÿ‡ซ๐Ÿ‡ฏ"}, + {"Flag: Falkland Islands", "๐Ÿ‡ซ๐Ÿ‡ฐ"}, + {"Flag: Micronesia", "๐Ÿ‡ซ๐Ÿ‡ฒ"}, + {"Flag: Faroe Islands", "๐Ÿ‡ซ๐Ÿ‡ด"}, + {"Flag: France", "๐Ÿ‡ซ๐Ÿ‡ท"}, + {"Flag: Gabon", "๐Ÿ‡ฌ๐Ÿ‡ฆ"}, + {"Flag: United Kingdom", "๐Ÿ‡ฌ๐Ÿ‡ง"}, + {"Flag: Grenada", "๐Ÿ‡ฌ๐Ÿ‡ฉ"}, + {"Flag: Georgia", "๐Ÿ‡ฌ๐Ÿ‡ช"}, + {"Flag: French Guiana", "๐Ÿ‡ฌ๐Ÿ‡ซ"}, + {"Flag: Guernsey", "๐Ÿ‡ฌ๐Ÿ‡ฌ"}, + {"Flag: Ghana", "๐Ÿ‡ฌ๐Ÿ‡ญ"}, + {"Flag: Gibraltar", "๐Ÿ‡ฌ๐Ÿ‡ฎ"}, + {"Flag: Greenland", "๐Ÿ‡ฌ๐Ÿ‡ฑ"}, + {"Flag: Gambia", "๐Ÿ‡ฌ๐Ÿ‡ฒ"}, + {"Flag: Guinea", "๐Ÿ‡ฌ๐Ÿ‡ณ"}, + {"Flag: Guadeloupe", "๐Ÿ‡ฌ๐Ÿ‡ต"}, + {"Flag: Equatorial Guinea", "๐Ÿ‡ฌ๐Ÿ‡ถ"}, + {"Flag: Greece", "๐Ÿ‡ฌ๐Ÿ‡ท"}, + {"Flag: South Georgia & South Sandwich Islands", "๐Ÿ‡ฌ๐Ÿ‡ธ"}, + {"Flag: Guatemala", "๐Ÿ‡ฌ๐Ÿ‡น"}, + {"Flag: Guam", "๐Ÿ‡ฌ๐Ÿ‡บ"}, + {"Flag: Guinea-Bissau", "๐Ÿ‡ฌ๐Ÿ‡ผ"}, + {"Flag: Guyana", "๐Ÿ‡ฌ๐Ÿ‡พ"}, + {"Flag: Hong Kong SAR China", "๐Ÿ‡ญ๐Ÿ‡ฐ"}, + {"Flag: Heard & McDonald Islands", "๐Ÿ‡ญ๐Ÿ‡ฒ"}, + {"Flag: Honduras", "๐Ÿ‡ญ๐Ÿ‡ณ"}, + {"Flag: Croatia", "๐Ÿ‡ญ๐Ÿ‡ท"}, + {"Flag: Haiti", "๐Ÿ‡ญ๐Ÿ‡น"}, + {"Flag: Hungary", "๐Ÿ‡ญ๐Ÿ‡บ"}, + {"Flag: Canary Islands", "๐Ÿ‡ฎ๐Ÿ‡จ"}, + {"Flag: Indonesia", "๐Ÿ‡ฎ๐Ÿ‡ฉ"}, + {"Flag: Ireland", "๐Ÿ‡ฎ๐Ÿ‡ช"}, + {"Flag: Israel", "๐Ÿ‡ฎ๐Ÿ‡ฑ"}, + {"Flag: Isle of Man", "๐Ÿ‡ฎ๐Ÿ‡ฒ"}, + {"Flag: India", "๐Ÿ‡ฎ๐Ÿ‡ณ"}, + {"Flag: British Indian Ocean Territory", "๐Ÿ‡ฎ๐Ÿ‡ด"}, + {"Flag: Iraq", "๐Ÿ‡ฎ๐Ÿ‡ถ"}, + {"Flag: Iran", "๐Ÿ‡ฎ๐Ÿ‡ท"}, + {"Flag: Iceland", "๐Ÿ‡ฎ๐Ÿ‡ธ"}, + {"Flag: Italy", "๐Ÿ‡ฎ๐Ÿ‡น"}, + {"Flag: Jersey", "๐Ÿ‡ฏ๐Ÿ‡ช"}, + {"Flag: Jamaica", "๐Ÿ‡ฏ๐Ÿ‡ฒ"}, + {"Flag: Jordan", "๐Ÿ‡ฏ๐Ÿ‡ด"}, + {"Flag: Japan", "๐Ÿ‡ฏ๐Ÿ‡ต"}, + {"Flag: Kenya", "๐Ÿ‡ฐ๐Ÿ‡ช"}, + {"Flag: Kyrgyzstan", "๐Ÿ‡ฐ๐Ÿ‡ฌ"}, + {"Flag: Cambodia", "๐Ÿ‡ฐ๐Ÿ‡ญ"}, + {"Flag: Kiribati", "๐Ÿ‡ฐ๐Ÿ‡ฎ"}, + {"Flag: Comoros", "๐Ÿ‡ฐ๐Ÿ‡ฒ"}, + {"Flag: St. Kitts & Nevis", "๐Ÿ‡ฐ๐Ÿ‡ณ"}, + {"Flag: North Korea", "๐Ÿ‡ฐ๐Ÿ‡ต"}, + {"Flag: South Korea", "๐Ÿ‡ฐ๐Ÿ‡ท"}, + {"Flag: Kuwait", "๐Ÿ‡ฐ๐Ÿ‡ผ"}, + {"Flag: Cayman Islands", "๐Ÿ‡ฐ๐Ÿ‡พ"}, + {"Flag: Kazakhstan", "๐Ÿ‡ฐ๐Ÿ‡ฟ"}, + {"Flag: Laos", "๐Ÿ‡ฑ๐Ÿ‡ฆ"}, + {"Flag: Lebanon", "๐Ÿ‡ฑ๐Ÿ‡ง"}, + {"Flag: St. Lucia", "๐Ÿ‡ฑ๐Ÿ‡จ"}, + {"Flag: Liechtenstein", "๐Ÿ‡ฑ๐Ÿ‡ฎ"}, + {"Flag: Sri Lanka", "๐Ÿ‡ฑ๐Ÿ‡ฐ"}, + {"Flag: Liberia", "๐Ÿ‡ฑ๐Ÿ‡ท"}, + {"Flag: Lesotho", "๐Ÿ‡ฑ๐Ÿ‡ธ"}, + {"Flag: Lithuania", "๐Ÿ‡ฑ๐Ÿ‡น"}, + {"Flag: Luxembourg", "๐Ÿ‡ฑ๐Ÿ‡บ"}, + {"Flag: Latvia", "๐Ÿ‡ฑ๐Ÿ‡ป"}, + {"Flag: Libya", "๐Ÿ‡ฑ๐Ÿ‡พ"}, + {"Flag: Morocco", "๐Ÿ‡ฒ๐Ÿ‡ฆ"}, + {"Flag: Monaco", "๐Ÿ‡ฒ๐Ÿ‡จ"}, + {"Flag: Moldova", "๐Ÿ‡ฒ๐Ÿ‡ฉ"}, + {"Flag: Montenegro", "๐Ÿ‡ฒ๐Ÿ‡ช"}, + {"Flag: St. Martin", "๐Ÿ‡ฒ๐Ÿ‡ซ"}, + {"Flag: Madagascar", "๐Ÿ‡ฒ๐Ÿ‡ฌ"}, + {"Flag: Marshall Islands", "๐Ÿ‡ฒ๐Ÿ‡ญ"}, + {"Flag: North Macedonia", "๐Ÿ‡ฒ๐Ÿ‡ฐ"}, + {"Flag: Mali", "๐Ÿ‡ฒ๐Ÿ‡ฑ"}, + {"Flag: Myanmar (Burma)", "๐Ÿ‡ฒ๐Ÿ‡ฒ"}, + {"Flag: Mongolia", "๐Ÿ‡ฒ๐Ÿ‡ณ"}, + {"Flag: Macao Sar China", "๐Ÿ‡ฒ๐Ÿ‡ด"}, + {"Flag: Northern Mariana Islands", "๐Ÿ‡ฒ๐Ÿ‡ต"}, + {"Flag: Martinique", "๐Ÿ‡ฒ๐Ÿ‡ถ"}, + {"Flag: Mauritania", "๐Ÿ‡ฒ๐Ÿ‡ท"}, + {"Flag: Montserrat", "๐Ÿ‡ฒ๐Ÿ‡ธ"}, + {"Flag: Malta", "๐Ÿ‡ฒ๐Ÿ‡น"}, + {"Flag: Mauritius", "๐Ÿ‡ฒ๐Ÿ‡บ"}, + {"Flag: Maldives", "๐Ÿ‡ฒ๐Ÿ‡ป"}, + {"Flag: Malawi", "๐Ÿ‡ฒ๐Ÿ‡ผ"}, + {"Flag: Mexico", "๐Ÿ‡ฒ๐Ÿ‡ฝ"}, + {"Flag: Malaysia", "๐Ÿ‡ฒ๐Ÿ‡พ"}, + {"Flag: Mozambique", "๐Ÿ‡ฒ๐Ÿ‡ฟ"}, + {"Flag: Namibia", "๐Ÿ‡ณ๐Ÿ‡ฆ"}, + {"Flag: New Caledonia", "๐Ÿ‡ณ๐Ÿ‡จ"}, + {"Flag: Niger", "๐Ÿ‡ณ๐Ÿ‡ช"}, + {"Flag: Norfolk Island", "๐Ÿ‡ณ๐Ÿ‡ซ"}, + {"Flag: Nigeria", "๐Ÿ‡ณ๐Ÿ‡ฌ"}, + {"Flag: Nicaragua", "๐Ÿ‡ณ๐Ÿ‡ฎ"}, + {"Flag: Netherlands", "๐Ÿ‡ณ๐Ÿ‡ฑ"}, + {"Flag: Norway", "๐Ÿ‡ณ๐Ÿ‡ด"}, + {"Flag: Nepal", "๐Ÿ‡ณ๐Ÿ‡ต"}, + {"Flag: Nauru", "๐Ÿ‡ณ๐Ÿ‡ท"}, + {"Flag: Niue", "๐Ÿ‡ณ๐Ÿ‡บ"}, + {"Flag: New Zealand", "๐Ÿ‡ณ๐Ÿ‡ฟ"}, + {"Flag: Oman", "๐Ÿ‡ด๐Ÿ‡ฒ"}, + {"Flag: Panama", "๐Ÿ‡ต๐Ÿ‡ฆ"}, + {"Flag: Peru", "๐Ÿ‡ต๐Ÿ‡ช"}, + {"Flag: French Polynesia", "๐Ÿ‡ต๐Ÿ‡ซ"}, + {"Flag: Papua New Guinea", "๐Ÿ‡ต๐Ÿ‡ฌ"}, + {"Flag: Philippines", "๐Ÿ‡ต๐Ÿ‡ญ"}, + {"Flag: Pakistan", "๐Ÿ‡ต๐Ÿ‡ฐ"}, + {"Flag: Poland", "๐Ÿ‡ต๐Ÿ‡ฑ"}, + {"Flag: St. Pierre & Miquelon", "๐Ÿ‡ต๐Ÿ‡ฒ"}, + {"Flag: Pitcairn Islands", "๐Ÿ‡ต๐Ÿ‡ณ"}, + {"Flag: Puerto Rico", "๐Ÿ‡ต๐Ÿ‡ท"}, + {"Flag: Palestinian Territories", "๐Ÿ‡ต๐Ÿ‡ธ"}, + {"Flag: Portugal", "๐Ÿ‡ต๐Ÿ‡น"}, + {"Flag: Palau", "๐Ÿ‡ต๐Ÿ‡ผ"}, + {"Flag: Paraguay", "๐Ÿ‡ต๐Ÿ‡พ"}, + {"Flag: Qatar", "๐Ÿ‡ถ๐Ÿ‡ฆ"}, + {"Flag: Rรฉunion", "๐Ÿ‡ท๐Ÿ‡ช"}, + {"Flag: Romania", "๐Ÿ‡ท๐Ÿ‡ด"}, + {"Flag: Serbia", "๐Ÿ‡ท๐Ÿ‡ธ"}, + {"Flag: Russia", "๐Ÿ‡ท๐Ÿ‡บ"}, + {"Flag: Rwanda", "๐Ÿ‡ท๐Ÿ‡ผ"}, + {"Flag: Saudi Arabia", "๐Ÿ‡ธ๐Ÿ‡ฆ"}, + {"Flag: Solomon Islands", "๐Ÿ‡ธ๐Ÿ‡ง"}, + {"Flag: Seychelles", "๐Ÿ‡ธ๐Ÿ‡จ"}, + {"Flag: Sudan", "๐Ÿ‡ธ๐Ÿ‡ฉ"}, + {"Flag: Sweden", "๐Ÿ‡ธ๐Ÿ‡ช"}, + {"Flag: Singapore", "๐Ÿ‡ธ๐Ÿ‡ฌ"}, + {"Flag: St. Helena", "๐Ÿ‡ธ๐Ÿ‡ญ"}, + {"Flag: Slovenia", "๐Ÿ‡ธ๐Ÿ‡ฎ"}, + {"Flag: Svalbard & Jan Mayen", "๐Ÿ‡ธ๐Ÿ‡ฏ"}, + {"Flag: Slovakia", "๐Ÿ‡ธ๐Ÿ‡ฐ"}, + {"Flag: Sierra Leone", "๐Ÿ‡ธ๐Ÿ‡ฑ"}, + {"Flag: San Marino", "๐Ÿ‡ธ๐Ÿ‡ฒ"}, + {"Flag: Senegal", "๐Ÿ‡ธ๐Ÿ‡ณ"}, + {"Flag: Somalia", "๐Ÿ‡ธ๐Ÿ‡ด"}, + {"Flag: Suriname", "๐Ÿ‡ธ๐Ÿ‡ท"}, + {"Flag: South Sudan", "๐Ÿ‡ธ๐Ÿ‡ธ"}, + {"Flag: Sรฃo Tomรฉ & Prรญncipe", "๐Ÿ‡ธ๐Ÿ‡น"}, + {"Flag: El Salvador", "๐Ÿ‡ธ๐Ÿ‡ป"}, + {"Flag: Sint Maarten", "๐Ÿ‡ธ๐Ÿ‡ฝ"}, + {"Flag: Syria", "๐Ÿ‡ธ๐Ÿ‡พ"}, + {"Flag: Eswatini", "๐Ÿ‡ธ๐Ÿ‡ฟ"}, + {"Flag: Tristan Da Cunha", "๐Ÿ‡น๐Ÿ‡ฆ"}, + {"Flag: Turks & Caicos Islands", "๐Ÿ‡น๐Ÿ‡จ"}, + {"Flag: Chad", "๐Ÿ‡น๐Ÿ‡ฉ"}, + {"Flag: French Southern Territories", "๐Ÿ‡น๐Ÿ‡ซ"}, + {"Flag: Togo", "๐Ÿ‡น๐Ÿ‡ฌ"}, + {"Flag: Thailand", "๐Ÿ‡น๐Ÿ‡ญ"}, + {"Flag: Tajikistan", "๐Ÿ‡น๐Ÿ‡ฏ"}, + {"Flag: Tokelau", "๐Ÿ‡น๐Ÿ‡ฐ"}, + {"Flag: Timor-Leste", "๐Ÿ‡น๐Ÿ‡ฑ"}, + {"Flag: Turkmenistan", "๐Ÿ‡น๐Ÿ‡ฒ"}, + {"Flag: Tunisia", "๐Ÿ‡น๐Ÿ‡ณ"}, + {"Flag: Tonga", "๐Ÿ‡น๐Ÿ‡ด"}, + {"Flag: Turkey", "๐Ÿ‡น๐Ÿ‡ท"}, + {"Flag: Trinidad & Tobago", "๐Ÿ‡น๐Ÿ‡น"}, + {"Flag: Tuvalu", "๐Ÿ‡น๐Ÿ‡ป"}, + {"Flag: Taiwan", "๐Ÿ‡น๐Ÿ‡ผ"}, + {"Flag: Tanzania", "๐Ÿ‡น๐Ÿ‡ฟ"}, + {"Flag: Ukraine", "๐Ÿ‡บ๐Ÿ‡ฆ"}, + {"Flag: Uganda", "๐Ÿ‡บ๐Ÿ‡ฌ"}, + {"Flag: U.S. Outlying Islands", "๐Ÿ‡บ๐Ÿ‡ฒ"}, + {"Flag: United Nations", "๐Ÿ‡บ๐Ÿ‡ณ"}, + {"Flag: United States", "๐Ÿ‡บ๐Ÿ‡ธ"}, + {"Flag: Uruguay", "๐Ÿ‡บ๐Ÿ‡พ"}, + {"Flag: Uzbekistan", "๐Ÿ‡บ๐Ÿ‡ฟ"}, + {"Flag: Vatican City", "๐Ÿ‡ป๐Ÿ‡ฆ"}, + {"Flag: St. Vincent & Grenadines", "๐Ÿ‡ป๐Ÿ‡จ"}, + {"Flag: Venezuela", "๐Ÿ‡ป๐Ÿ‡ช"}, + {"Flag: British Virgin Islands", "๐Ÿ‡ป๐Ÿ‡ฌ"}, + {"Flag: U.S. Virgin Islands", "๐Ÿ‡ป๐Ÿ‡ฎ"}, + {"Flag: Vietnam", "๐Ÿ‡ป๐Ÿ‡ณ"}, + {"Flag: Vanuatu", "๐Ÿ‡ป๐Ÿ‡บ"}, + {"Flag: Wallis & Futuna", "๐Ÿ‡ผ๐Ÿ‡ซ"}, + {"Flag: Samoa", "๐Ÿ‡ผ๐Ÿ‡ธ"}, + {"Flag: Kosovo", "๐Ÿ‡ฝ๐Ÿ‡ฐ"}, + {"Flag: Yemen", "๐Ÿ‡พ๐Ÿ‡ช"}, + {"Flag: Mayotte", "๐Ÿ‡พ๐Ÿ‡น"}, + {"Flag: South Africa", "๐Ÿ‡ฟ๐Ÿ‡ฆ"}, + {"Flag: Zambia", "๐Ÿ‡ฟ๐Ÿ‡ฒ"}, + {"Flag: Zimbabwe", "๐Ÿ‡ฟ๐Ÿ‡ผ"}, + {"Flag: England", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ"}, + {"Flag: Scotland", "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ"}, + {"Flag: Wales", "๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ"}, + {"Flag for Texas (US-TX)", "๐Ÿด๓ ต๓ ณ๓ ด๓ ธ๓ ฟ"}, + {"Refugee Nation Flag", "๐Ÿณโ€๐ŸŸงโ€โฌ›โ€๐ŸŸง"} + ] + } + + @type category() :: + :activity | :flags | :food | :nature | :objects | :people | :symbols | :travel + + @type emoji() :: {String.t(), String.t()} + + @spec categories :: [category()] + def categories(), do: @categories + + @spec emojis() :: %{category() => [TTClientUI.Live.Components.EmojiPicker.Emoji.t()]} + def emojis() do + for {category, emojis} <- @emojis, into: %{} do + emojis = + for {name, char} <- emojis do + %TTClientUI.Live.Components.EmojiPicker.Emoji{ + char: char, + name: name + } + end + + {category, emojis} + end + end + + @spec category_icon(category()) :: String.t() + def category_icon(category) + + def category_icon(:favourites), do: "โญ๏ธ" + def category_icon(:activity), do: "โšฝ" + def category_icon(:flags), do: "๐ŸŽŒ" + def category_icon(:food), do: "๐Ÿ”" + def category_icon(:nature), do: "๐Ÿป" + def category_icon(:objects), do: "๐Ÿ’ก" + def category_icon(:people), do: "๐Ÿ˜ƒ" + def category_icon(:symbols), do: "๐Ÿ’•" + def category_icon(:travel), do: "๐Ÿš€" +end diff --git a/lib/t_t_client_u_i/live/components/emoji_picker/emoji.ex b/lib/t_t_client_u_i/live/components/emoji_picker/emoji.ex new file mode 100644 index 0000000..ab4cfad --- /dev/null +++ b/lib/t_t_client_u_i/live/components/emoji_picker/emoji.ex @@ -0,0 +1,18 @@ +defmodule TTClientUI.Live.Components.EmojiPicker.Emoji do + import TalkTool.TypedStruct + + deftypedstruct(%{ + char: String.t(), + name: String.t() + }) + + defimpl String.Chars, for: __MODULE__ do + @spec to_string(TTClientUI.Live.Components.EmojiPicker.Emoji.t()) :: String.t() + def to_string(emoji), do: emoji.char + end + + defimpl Phoenix.HTML.Safe, for: __MODULE__ do + @spec to_iodata(TTClientUI.Live.Components.EmojiPicker.Emoji.t()) :: String.t() + def to_iodata(emoji), do: emoji.char + end +end diff --git a/lib/t_t_client_u_i/live/components/emoji_picker/emoji_picker.ex b/lib/t_t_client_u_i/live/components/emoji_picker/emoji_picker.ex new file mode 100644 index 0000000..e6f401a --- /dev/null +++ b/lib/t_t_client_u_i/live/components/emoji_picker/emoji_picker.ex @@ -0,0 +1,59 @@ +defmodule TTClientUI.Live.Components.EmojiPicker do + use Phoenix.LiveComponent + + alias __MODULE__.Data + alias Phoenix.LiveView.JS + + @impl Phoenix.LiveComponent + def mount(socket) do + categories = Data.categories() + emojis = Data.emojis() + {:ok, assign(socket, open_view: nil, categories: categories, emojis: emojis)} + end + + @impl Phoenix.LiveComponent + def render(assigns) do + ~H""" +
+
+ <%= for c <- @categories do %> + + <% end %> +
+ +
+ <%= for c <- @categories do %> +
+ <%= for emoji <- Map.fetch!(@emojis, c) do %> + + <% end %> +
+ <% end %> +
+
+ """ + end + + @spec element_id(String.t()) :: String.t() + defp element_id(id), do: "emoji-picker-#{id}" + + @spec content_id(String.t()) :: String.t() + defp content_id(id), do: "#{element_id(id)}-content" + + @spec category_id(String.t(), Data.category()) :: String.t() + defp category_id(id, category), do: "#{element_id(id)}-category-#{category}" +end diff --git a/lib/t_t_client_u_i/live/components/screenshot/screenshot.ex b/lib/t_t_client_u_i/live/components/screenshot/screenshot.ex new file mode 100644 index 0000000..f20f5d8 --- /dev/null +++ b/lib/t_t_client_u_i/live/components/screenshot/screenshot.ex @@ -0,0 +1,23 @@ +defmodule TTClientUI.Live.Components.Screenshot do + use Phoenix.LiveComponent + + @impl Phoenix.LiveComponent + def mount(socket) do + {:ok, + assign(socket, + picture: nil + )} + end + + @impl Phoenix.LiveComponent + def handle_event(event, params, socket) + + def handle_event("take-screenshot", _params, socket) do + if socket.assigns.admin_pid != nil do + picture = GenServer.call(socket.assigns.admin_pid, :take_screenshot) + {:noreply, assign(socket, picture: picture)} + else + {:noreply, socket} + end + end +end diff --git a/lib/t_t_client_u_i/live/components/screenshot/screenshot.html.heex b/lib/t_t_client_u_i/live/components/screenshot/screenshot.html.heex new file mode 100644 index 0000000..bc72e68 --- /dev/null +++ b/lib/t_t_client_u_i/live/components/screenshot/screenshot.html.heex @@ -0,0 +1,30 @@ +
+
+ <%= if @picture != nil do %> + + <% else %> +

Take a screenshot by using the button below.

+ <% end %> +
+ + + + <%= if @picture != nil do %> + <%= if @mode == :individual do %> + + <% else %> + + <% end %> + <% end %> + + <%= inspect(@presentation.can_screenshot) %> <%= inspect(@admin_pid) %> +
diff --git a/lib/t_t_client_u_i/live/main.ex b/lib/t_t_client_u_i/live/main.ex new file mode 100644 index 0000000..ff43611 --- /dev/null +++ b/lib/t_t_client_u_i/live/main.ex @@ -0,0 +1,12 @@ +defmodule TTClientUI.Live.Main do + use Phoenix.LiveView, layout: {TTClientUI.LayoutView, "live.html"} + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTClientUI.Router.Helpers, as: Routes + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, assign(socket, page_title: "TalkTool")} + end +end diff --git a/lib/t_t_client_u_i/live/main.html.heex b/lib/t_t_client_u_i/live/main.html.heex new file mode 100644 index 0000000..0ec6c76 --- /dev/null +++ b/lib/t_t_client_u_i/live/main.html.heex @@ -0,0 +1,11 @@ +
+
+

TalkTool

+
+ +

+ You will need to open a presentation with a specific URL that you can get from the presenter. +

+ +

Please ask your presenter for more information.

+
diff --git a/lib/t_t_client_u_i/live/screenshot.ex b/lib/t_t_client_u_i/live/screenshot.ex new file mode 100644 index 0000000..b4a55c8 --- /dev/null +++ b/lib/t_t_client_u_i/live/screenshot.ex @@ -0,0 +1,41 @@ +defmodule TTClientUI.Live.Screenshot do + use Phoenix.LiveView, layout: TTClientUI.LayoutView.config() + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTClientUI.Router.Helpers, as: Routes + + alias TTAdmin.Presentation.PubSub + alias TTAdmin.Presentation.Server + + @impl Phoenix.LiveView + def mount(params, _session, socket) do + server_pid = Server.get_server!(Map.get(params, "id")) + presentation = Server.get(server_pid) + admin_pid = Server.get_admin(server_pid) + + if connected?(socket) do + PubSub.listen_presentation(presentation) + PubSub.listen_admin_pid(presentation) + end + + {:ok, + assign(socket, + page_title: "Grab a Screenshot", + presentation: presentation, + admin_pid: admin_pid, + picture: nil + )} + end + + @impl Phoenix.LiveView + def handle_info(msg, socket) + + def handle_info({:update_presentation, presentation}, socket) do + {:noreply, assign(socket, presentation: presentation)} + end + + def handle_info({:new_admin_pid, pid}, socket) do + {:noreply, assign(socket, admin_pid: pid)} + end +end diff --git a/lib/t_t_client_u_i/live/screenshot.html.heex b/lib/t_t_client_u_i/live/screenshot.html.heex new file mode 100644 index 0000000..f074777 --- /dev/null +++ b/lib/t_t_client_u_i/live/screenshot.html.heex @@ -0,0 +1,17 @@ +
+
+

Screenshot

+
+ + <.live_component + id="screenshot-component" + module={TTClientUI.Live.Components.Screenshot} + presentation={@presentation} + admin_pid={@admin_pid} + mode={:individual} + /> + + <.link navigate={Routes.live_path(@socket, TTClientUI.Live.ViewPresentation, @presentation.id)}> + Back + +
diff --git a/lib/t_t_client_u_i/live/view_presentation.ex b/lib/t_t_client_u_i/live/view_presentation.ex new file mode 100644 index 0000000..e56cad4 --- /dev/null +++ b/lib/t_t_client_u_i/live/view_presentation.ex @@ -0,0 +1,51 @@ +defmodule TTClientUI.Live.ViewPresentation do + use Phoenix.LiveView, layout: TTClientUI.LayoutView.config() + use Phoenix.HTML + import Phoenix.LiveView.Helpers + import Phoenix.View + alias TTClientUI.Router.Helpers, as: Routes + alias Phoenix.LiveView.JS + + alias TTAdmin.Schemas.Reaction + alias TTAdmin.Storage.DB + alias TTAdmin.Presentation.Server + + @impl Phoenix.LiveView + def mount(params, _session, socket) do + server_pid = Server.get_server!(Map.get(params, "id")) + presentation = Server.get(server_pid) + + if connected?(socket) do + TTAdmin.Presentation.PubSub.listen_presentation(presentation) + end + + {:ok, + assign(socket, + page_title: presentation.name, + presentation: presentation + )} + end + + @impl Phoenix.LiveView + def handle_event(evt, params, socket) + + def handle_event("emoji-select", %{"value" => emoji}, socket) do + changeset = Reaction.create_changeset(socket.assigns.presentation, %{emoji: emoji}) + + if changeset.valid? do + case DB.create_reaction(changeset) do + {:ok, reaction} -> TTAdmin.Presentation.PubSub.publish_reaction(reaction) + _ -> :ok + end + end + + {:noreply, socket} + end + + @impl Phoenix.LiveView + def handle_info(msg, socket) + + def handle_info({:update_presentation, presentation}, socket) do + {:noreply, assign(socket, presentation: presentation)} + end +end diff --git a/lib/t_t_client_u_i/live/view_presentation.html.heex b/lib/t_t_client_u_i/live/view_presentation.html.heex new file mode 100644 index 0000000..4d10e2b --- /dev/null +++ b/lib/t_t_client_u_i/live/view_presentation.html.heex @@ -0,0 +1,31 @@ +
+
+

<%= @presentation.name %>

+
+ +
+ + + <.link + navigate={Routes.live_path(@socket, TTClientUI.Live.AskQuestion, @presentation.id)} + class="button" + > + โ“ + + + <%= if @presentation.can_screenshot do %> + <.link + navigate={Routes.live_path(@socket, TTClientUI.Live.Screenshot, @presentation.id)} + class="button" + > + ๐Ÿ“ธ + + <% else %> + + <% end %> +
+ +
+ <.live_component id="emoji-picker" module={TTClientUI.Live.Components.EmojiPicker} /> +
+
diff --git a/lib/t_t_client_u_i/router.ex b/lib/t_t_client_u_i/router.ex new file mode 100644 index 0000000..17c4f9a --- /dev/null +++ b/lib/t_t_client_u_i/router.ex @@ -0,0 +1,23 @@ +defmodule TTClientUI.Router do + use Phoenix.Router + import Phoenix.LiveView.Router + + pipeline :browser do + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, {TTClientUI.LayoutView, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(TTUICommon.FLoCPlug) + end + + scope "/" do + pipe_through(:browser) + + live("/", TTClientUI.Live.Main) + live("/presentation/:id", TTClientUI.Live.ViewPresentation) + live("/presentation/:id/question", TTClientUI.Live.AskQuestion) + live("/presentation/:id/screenshot", TTClientUI.Live.Screenshot) + end +end diff --git a/lib/t_t_client_u_i/templates/error/404.html.heex b/lib/t_t_client_u_i/templates/error/404.html.heex new file mode 100644 index 0000000..b0cdc36 --- /dev/null +++ b/lib/t_t_client_u_i/templates/error/404.html.heex @@ -0,0 +1,7 @@ +
+
+

404

+
+ +

The specified page cannot be found. Please try some other page.

+
diff --git a/lib/t_t_client_u_i/templates/error/500.html.heex b/lib/t_t_client_u_i/templates/error/500.html.heex new file mode 100644 index 0000000..405819a --- /dev/null +++ b/lib/t_t_client_u_i/templates/error/500.html.heex @@ -0,0 +1,7 @@ +
+
+

500

+
+ +

Something blew up, oops.

+
diff --git a/lib/t_t_client_u_i/templates/error/root.html.heex b/lib/t_t_client_u_i/templates/error/root.html.heex new file mode 100644 index 0000000..02b5a46 --- /dev/null +++ b/lib/t_t_client_u_i/templates/error/root.html.heex @@ -0,0 +1,27 @@ + + + + + + + <%= Phoenix.HTML.Tag.csrf_meta_tag() %> + + <%= assigns[:page_title] %> + + + + + +
<%= @inner_content %>
+ + diff --git a/lib/t_t_client_u_i/templates/layout/live.html.heex b/lib/t_t_client_u_i/templates/layout/live.html.heex new file mode 100644 index 0000000..47507c9 --- /dev/null +++ b/lib/t_t_client_u_i/templates/layout/live.html.heex @@ -0,0 +1,11 @@ +
+ + + + + <%= @inner_content %> +
diff --git a/lib/t_t_client_u_i/templates/layout/root.html.heex b/lib/t_t_client_u_i/templates/layout/root.html.heex new file mode 100644 index 0000000..0d54978 --- /dev/null +++ b/lib/t_t_client_u_i/templates/layout/root.html.heex @@ -0,0 +1,27 @@ + + + + + + + <%= Phoenix.HTML.Tag.csrf_meta_tag() %> + + <%= assigns[:page_title] %> + + + + + + <%= @inner_content %> + + diff --git a/lib/t_t_u_i_common/db_errors.ex b/lib/t_t_u_i_common/db_errors.ex new file mode 100644 index 0000000..667a1ff --- /dev/null +++ b/lib/t_t_u_i_common/db_errors.ex @@ -0,0 +1,4 @@ +defimpl Plug.Exception, for: TTAdmin.Storage.DB.NoResultsError do + def status(_exception), do: 404 + def actions(_exception), do: [] +end diff --git a/lib/t_t_u_i_common/floc_plug.ex b/lib/t_t_u_i_common/floc_plug.ex new file mode 100644 index 0000000..e399f60 --- /dev/null +++ b/lib/t_t_u_i_common/floc_plug.ex @@ -0,0 +1,16 @@ +defmodule TTUICommon.FLoCPlug do + @moduledoc """ + Plug to opt out of Chrome's Federated Learning of Cohorts. + """ + + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + @spec call(Plug.Conn.t(), any) :: Plug.Conn.t() + def call(conn, _opts) do + Plug.Conn.put_resp_header(conn, "permissions-policy", "interest-cohort=()") + end +end diff --git a/lib/t_t_u_i_common/plug_helpers.ex b/lib/t_t_u_i_common/plug_helpers.ex new file mode 100644 index 0000000..7c1cc12 --- /dev/null +++ b/lib/t_t_u_i_common/plug_helpers.ex @@ -0,0 +1,6 @@ +defmodule TTUICommon.PlugHelpers do + @spec build_plug(module(), Plug.opts()) :: {module(), Plug.opts()} + def build_plug(plug_mod, opts \\ []) do + {plug_mod, plug_mod.init(opts)} + end +end diff --git a/lib/talk_tool/application.ex b/lib/talk_tool/application.ex new file mode 100644 index 0000000..def76c4 --- /dev/null +++ b/lib/talk_tool/application.ex @@ -0,0 +1,28 @@ +defmodule TalkTool.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl Application + def start(_type, _args) do + children = [ + Supervisor.child_spec({Phoenix.PubSub, name: TTAdmin.Presentation.PubSub}, + id: TTAdmin.Presentation.PubSub + ), + {Registry, keys: :unique, name: TTAdmin.Presentation.Server.Registry}, + {DynamicSupervisor, strategy: :one_for_one, name: TTAdmin.Presentation.Server.Supervisor}, + {CubDB, data_dir: Application.fetch_env!(:talk_tool, :db_dir), name: TTAdmin.Storage.DB}, + Supervisor.child_spec({Phoenix.PubSub, name: TTAdminUI.PubSub}, id: TTAdminUI.PubSub), + TTAdminUI.Endpoint, + Supervisor.child_spec({Phoenix.PubSub, name: TTClientUI.PubSub}, id: TTClientUI.PubSub), + TTClientUI.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: TalkTool.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/talk_tool/config_helpers.ex b/lib/talk_tool/config_helpers.ex new file mode 100644 index 0000000..affb06c --- /dev/null +++ b/lib/talk_tool/config_helpers.ex @@ -0,0 +1,35 @@ +defmodule TalkTool.ConfigHelpers do + @type config_type :: :str | :int | :bool | :json + + @doc """ + Get value from environment variable, converting it to the given type if needed. + + If no default value is given, or `:no_default` is given as the default, an error is raised if the variable is not + set. + """ + @spec get_env(String.t(), :no_default | any(), config_type()) :: any() + def get_env(var, default \\ :no_default, type \\ :str) + + def get_env(var, :no_default, type) do + System.fetch_env!(var) + |> get_with_type(type) + end + + def get_env(var, default, type) do + with {:ok, val} <- System.fetch_env(var) do + get_with_type(val, type) + else + :error -> default + end + end + + @spec get_with_type(String.t(), config_type()) :: any() + defp get_with_type(val, type) + + defp get_with_type(val, :str), do: val + defp get_with_type(val, :int), do: String.to_integer(val) + defp get_with_type("true", :bool), do: true + defp get_with_type("false", :bool), do: false + defp get_with_type(val, :json), do: Jason.decode!(val) + defp get_with_type(val, type), do: raise("Cannot convert to #{inspect(type)}: #{inspect(val)}") +end diff --git a/lib/talk_tool/typed_schema.ex b/lib/talk_tool/typed_schema.ex new file mode 100644 index 0000000..5e57ef0 --- /dev/null +++ b/lib/talk_tool/typed_schema.ex @@ -0,0 +1,108 @@ +defmodule TalkTool.TypedSchema do + @doc """ + Define an Ecto schema with an associated `@type t` specification. + + Works the same as normal Ecto schemas, but third argument of each field is the typespec to use + for that field. Typespec for `timestamps` is automatically generated and cannot be specified. + Supported Ecto macros are `field`, `belongs_to`, `has_many`, `has_one`. `many_to_many` is not + supported. + + Note: For `has_many`, remember to specify the typespec as a list. + + Does not work for embedded schemas. + """ + defmacro deftypedschema(table, do: fields) do + fields = + case fields do + {:__block__, _meta, flist} -> flist + field -> [field] + end + + fielddatas = for field <- fields, do: parse_spec(field) + + typespecs = + Enum.reduce(fielddatas, [], fn + %{field: :timestamps, fieldspec: {:timestamps, _, [opts]}}, acc -> + acc = + if Keyword.get(opts, :updated_at, true) != false do + [{:updated_at, quote(do: DateTime.t())} | acc] + else + acc + end + + if Keyword.get(opts, :inserted_at, true) != false do + [{:inserted_at, quote(do: DateTime.t())} | acc] + else + acc + end + + %{field: field, typespec: typespec, func: func}, acc -> + acc = [{field, typespec} | acc] + + if func == :belongs_to do + # If given spec includes nil, add nil to ID spec too + spec = + case typespec do + {:|, _, [x, y]} when is_nil(x) or is_nil(y) -> quote(do: pos_integer() | nil) + _ -> quote(do: pos_integer()) + end + + [{String.to_atom("#{field}_id"), spec} | acc] + else + acc + end + end) + |> Enum.reverse() + + fieldspecs = Enum.map(fielddatas, & &1.fieldspec) + + if table == :embedded do + quote do + use Ecto.Schema + + @type t :: %__MODULE__{ + unquote_splicing(typespecs), + id: Ecto.UUID.t() + } + + embedded_schema do + (unquote_splicing(fieldspecs)) + end + end + else + quote do + use Ecto.Schema + + @type t :: %__MODULE__{ + unquote_splicing(typespecs), + __meta__: Ecto.Schema.Metadata.t(), + id: pos_integer() + } + + schema unquote(table) do + (unquote_splicing(fieldspecs)) + end + end + end + end + + defp parse_spec(ast) + + defp parse_spec({:timestamps, _meta, _args} = ast) do + %{ + field: :timestamps, + func: :timestamps, + fieldspec: ast, + typespec: nil + } + end + + defp parse_spec({func, meta, [field, type, typespec | rest]}) do + %{ + field: field, + func: func, + fieldspec: {func, meta, [field, type | rest]}, + typespec: typespec + } + end +end diff --git a/lib/talk_tool/typed_struct.ex b/lib/talk_tool/typed_struct.ex new file mode 100644 index 0000000..5f76022 --- /dev/null +++ b/lib/talk_tool/typed_struct.ex @@ -0,0 +1,74 @@ +defmodule TalkTool.TypedStruct do + @doc ~S/ + Create typed struct with a type, default values, and enforced keys. + + Input should be a map where the key names are names of the struct keys and values are the + field information. The value can be a typespec, in which case the field will be enforced, or + a 2-tuple of `{typespec, default_value}`, making the field unenforced. + + To prevent ambiguity, a value of `{typespec, :ts_enforced}` will be interpreted as enforced, + this will allow you to typespec a 2-tuple. + + NOTE: Due to the ambiguity removal technique above, `:ts_enforced` is not allowed as a default + value. + + Example: + + ```elixir + deftypedstruct(%{ + # Enforced with simple type + foo: integer(), + + # Enforced 2-tuple typed field, written like this to remove ambiguity + bar: {{String.t(), integer()}, :ts_enforced}, + + # Non-enforced field with default value + baz: {any(), ""} + }, """ + Optional typedoc for the struct type `t`. + """) + ``` + / + defmacro deftypedstruct(fields, typedoc \\ "") do + fields_list = + case fields do + {:%{}, _, flist} -> flist + _ -> raise ArgumentError, "Fields must be a map!" + end + + enforced_list = + fields_list + |> Enum.filter(fn + {_, {_, :ts_enforced}} -> true + {_, {_, _}} -> false + {_, _} -> true + end) + |> Enum.map(&elem(&1, 0)) + + field_specs = + Enum.map(fields_list, fn + {field, {typespec, :ts_enforced}} -> + {field, typespec} + + {field, {typespec, _}} -> + {field, typespec} + + {field, typespec} -> + {field, typespec} + end) + + field_vals = + Enum.map(fields_list, fn + {field, {_, :ts_enforced}} -> field + {field, {_, default}} -> {field, default} + {field, _} -> field + end) + + quote do + @typedoc unquote(typedoc) + @type t :: %__MODULE__{unquote_splicing(field_specs)} + @enforce_keys unquote(enforced_list) + defstruct unquote(field_vals) + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..9011420 --- /dev/null +++ b/mix.exs @@ -0,0 +1,40 @@ +defmodule TalkTool.MixProject do + use Mix.Project + + def project do + [ + app: :talk_tool, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {TalkTool.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:phoenix, "~> 1.6.13"}, + {:phoenix_live_view, "~> 0.18.0"}, + {:phoenix_html, "~> 3.2"}, + {:phoenix_pubsub, "~> 2.1"}, + {:phoenix_view, "~> 1.1"}, + {:phoenix_live_reload, "~> 1.3"}, + {:plug_cowboy, "~> 2.5"}, + {:ecto, "~> 3.9.1"}, + {:phoenix_ecto, "~> 4.4"}, + {:jason, "~> 1.4"}, + {:dotenv_parser, "~> 2.0"}, + {:cubdb, "~> 2.0"}, + {:qr_code, "~> 2.3"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..7e6e60d --- /dev/null +++ b/mix.lock @@ -0,0 +1,34 @@ +%{ + "bandit": {:hex, :bandit, "0.5.6", "c81618003f457e77548348665450707cac18201cf6a1eb203a4e0f05b75a5701", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:sock, "~> 0.2.5", [hex: :sock, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.5.10", [hex: :thousand_island, repo: "hexpm", optional: false]}], "hexpm", "1e09e18ba5fce47838c57440b3c35733c0878e27dc9b2114669883004301c5c2"}, + "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "cubdb": {:hex, :cubdb, "2.0.1", "24cab8fb4128df704c52ed641f5ed70af352f7a3a80cebbb44c3bbadc3fd5f45", [:mix], [], "hexpm", "57cf25aebfc34f4580d9075da06882b4fe3e0739f5353d4dcc213e9cc1b10cdf"}, + "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "dotenv_parser": {:hex, :dotenv_parser, "2.0.0", "0f999196857e4ee18cbba1413018d5e4980ab16b397e3a2f8d0cf541fe683181", [:mix], [], "hexpm", "e769bde2dbff5b0cd0d9d877a9ccfd2c6dd84772dfb405d5a43cceb4f93616c5"}, + "ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"}, + "ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "matrix_reloaded": {:hex, :matrix_reloaded, "2.3.0", "eea41bc6713021f8f51dde0c2d6b72e695a99098753baebf0760e10aed8fa777", [:mix], [{:ex_maybe, "~> 1.0", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}], "hexpm", "4013c0cebe5dfffc8f2316675b642fb2f5a1dfc4bdc40d2c0dfa0563358fa496"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "phoenix": {:hex, :phoenix, "1.6.13", "5b3152907afdb8d3a6cdafb4b149e8aa7aabbf1422fd9f7ef4c2a67ead57d24a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13d8806c31176e2066da4df2d7443c144211305c506ed110ad4044335b90171d"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.2", "635cf07de947235deb030cd6b776c71a3b790ab04cebf526aa8c879fe17c7784", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6 or ~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "da287a77327e996cc166e4c440c3ad5ab33ccdb151b91c793209b39ebbce5b75"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "qr_code": {:hex, :qr_code, "2.3.1", "c195d8064921068a28807f93e6ac4f535e0c5d3fa639f73d04a0e2edd94b6eea", [:mix], [{:ex_maybe, "~> 1.1.1", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:matrix_reloaded, "~> 2.3", [hex: :matrix_reloaded, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}, {:xml_builder, "~> 2.2", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "7b98c36dda09a135206ebf49179e67a6bad5002101592ef1374abe01d4fe68fa"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "result": {:hex, :result, "1.7.2", "a57c569f7cf5c158d2299d3b5624a48b69bd1520d0771dc711bcf9f3916e8ab6", [:mix], [], "hexpm", "89f98e98cfbf64237ecf4913aa36b76b80463e087775d19953dc4b435a35f087"}, + "sock": {:hex, :sock, "0.2.5", "ba9c7e4b69f1749dc3063e482136af1c1a159e61a8081f5973e62e06186c0f56", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f51c78ab732a968b53ed46990fe749eea9b26d8582a72ef541decfc742e48b14"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "thousand_island": {:hex, :thousand_island, "0.5.11", "5771bd889b16e20bdad2f0ab9831017511c229485f6461193b78e72bf3df51d9", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e866dac9fdcd9225020021f7df1d8cef1896c0550684e9c0cc28ebb2cc3085b"}, + "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, +} diff --git a/priv/static/admin/app.css b/priv/static/admin/app.css new file mode 100644 index 0000000..ca88c78 --- /dev/null +++ b/priv/static/admin/app.css @@ -0,0 +1,20 @@ +@import "../common/common.css"; +@import "./emoji-reaction.css"; +@import "./screenshot.css"; + +.question { + margin-top: 2.5rem; + border-bottom: 1px solid #9b4dca; +} + +.question:last-of-type { + border-bottom: none; +} + +.answered .question-text { + text-decoration: line-through; +} + +#share-qr { + display: none; +} diff --git a/priv/static/admin/app.js b/priv/static/admin/app.js new file mode 100644 index 0000000..8f31a47 --- /dev/null +++ b/priv/static/admin/app.js @@ -0,0 +1,92 @@ +import { Socket } from "../common/phx.js"; +import { LiveSocket } from "../common/lv.js"; +import "../common/vendor/topbar.js"; +import { emojiReactionHook } from "./emoji-reaction.js"; +import { askForScreenshotPerm, takeScreenshot } from "./screenshot.js"; + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); + +let mediaStream = null; + +const hooks = { + EmojiReaction: emojiReactionHook, + Screenshot: { + mounted() { + const check = document.getElementById("screenshot-check"); + const preview = document.getElementById("screenshot-preview"); + + check.addEventListener("click", async () => { + if (check.checked) { + if (!mediaStream) { + const video = document.createElement("video"); + video.setAttribute("id", "screenshot-video"); + video.setAttribute("autoplay", "autoplay"); + preview.appendChild(video); + + mediaStream = await askForScreenshotPerm(); + + if (mediaStream) { + for (const track of mediaStream.getVideoTracks()) { + track.addEventListener("ended", () => { + this.pushEvent("got-screenshot-perm", { has: false }); + mediaStream = null; + video.remove(); + }); + } + + video.srcObject = mediaStream; + } else { + check.checked = false; + video.remove(); + } + } + + this.pushEvent("got-screenshot-perm", { has: !!mediaStream }); + } else { + for (const child of Array.from(preview.children)) { + preview.removeChild(child); + } + + if (mediaStream) { + for (const track of mediaStream.getVideoTracks()) { + track.stop(); + mediaStream = null; + } + } + + check.checked = false; + + this.pushEvent("got-screenshot-perm", { has: false }); + } + }); + + this.handleEvent("take-screenshot", () => { + if (!mediaStream) { + return; + } + + const screenshot = takeScreenshot(mediaStream); + if (screenshot) { + this.pushEvent("took-screenshot", { data: screenshot }); + } + }); + } + } +}; + +let liveSocket = new LiveSocket("/live", Socket, { hooks, params: { _csrf_token: csrfToken } }); + +// Show progress bar on live navigation and form submits +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", info => topbar.show()); +window.addEventListener("phx:page-loading-stop", info => topbar.hide()); + +// connect if there are any LiveViews on the page +liveSocket.connect(); + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket; + diff --git a/priv/static/admin/emoji-reaction.css b/priv/static/admin/emoji-reaction.css new file mode 100644 index 0000000..109f462 --- /dev/null +++ b/priv/static/admin/emoji-reaction.css @@ -0,0 +1,13 @@ +#emoji-reactions { + position: fixed; + bottom: 20rem; +} + +.emoji-reaction { + display: block; + + position: fixed; + left: 0; + + font-size: 10rem; +} diff --git a/priv/static/admin/emoji-reaction.js b/priv/static/admin/emoji-reaction.js new file mode 100644 index 0000000..e969034 --- /dev/null +++ b/priv/static/admin/emoji-reaction.js @@ -0,0 +1,18 @@ +const EMOJI_TIME = 5_000; + +export const emojiReactionHook = { + mounted() { + let position = Math.random() * window.innerWidth - 50; + position = Math.max(0, position); + position = Math.min(window.innerWidth - 120, position); + this.el.style.left = `${position}px`; + + this.el.classList.add("fade-out"); + + window.setTimeout(() => { + if (this.el) { + this.el.remove(); + } + }, EMOJI_TIME); + } +}; diff --git a/priv/static/admin/screenshot.css b/priv/static/admin/screenshot.css new file mode 100644 index 0000000..bafc6e9 --- /dev/null +++ b/priv/static/admin/screenshot.css @@ -0,0 +1,7 @@ +#screenshot-video { + max-width: 100%; +} + +.screenshot { + max-width: 100%; +} diff --git a/priv/static/admin/screenshot.js b/priv/static/admin/screenshot.js new file mode 100644 index 0000000..43db5ef --- /dev/null +++ b/priv/static/admin/screenshot.js @@ -0,0 +1,41 @@ +export async function askForScreenshotPerm() { + try { + return await navigator.mediaDevices.getDisplayMedia({ video: { cursor: "always" } }); + } catch (e) { + console.error(e); + return null; + } +} + +export function takeScreenshot(mediaStream) { + const canvas = document.createElement("canvas"); + const video = document.getElementById("screenshot-video"); + + try { + const track = mediaStream.getVideoTracks()[0]; + + if (track && video) { + let { width, height } = track.getSettings(); + width = width || window.innerWidth; + height = height || window.innerHeight; + + canvas.width = width; + canvas.height = height; + + if (video.srcObject !== mediaStream) { + video.srcObject = mediaStream; + } + + const context = canvas.getContext("2d"); + context.drawImage(video, 0, 0); + const frame = canvas.toDataURL("image/png"); + + return frame; + } else { + return null; + } + } catch (e) { + console.error(e); + return null; + } +} diff --git a/priv/static/client/app.css b/priv/static/client/app.css new file mode 100644 index 0000000..73f73c3 --- /dev/null +++ b/priv/static/client/app.css @@ -0,0 +1,16 @@ +@import "../common/common.css"; +@import "./emoji-picker.css"; +@import "./screenshot.css"; + +.view-presentation { + display: flex; + flex-direction: column; + + max-height: 95vh; + overflow: hidden; +} + +.view-presentation #emoji-picker-wrapper { + flex-grow: 1; + min-height: 0; +} diff --git a/priv/static/client/app.js b/priv/static/client/app.js new file mode 100644 index 0000000..1f11145 --- /dev/null +++ b/priv/static/client/app.js @@ -0,0 +1,29 @@ +import { Socket } from "../common/phx.js"; +import { LiveSocket } from "../common/lv.js"; +import "../common/vendor/topbar.js"; +import { emojiPickerHook } from "./emoji-picker.js"; +import { screenshotDownloadHook } from "./screenshot.js"; + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); + +const hooks = { + EmojiPicker: emojiPickerHook, + ScreenshotDownload: screenshotDownloadHook, +} + +let liveSocket = new LiveSocket("/live", Socket, { hooks, params: { _csrf_token: csrfToken } }); + +// Show progress bar on live navigation and form submits +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", info => topbar.show()); +window.addEventListener("phx:page-loading-stop", info => topbar.hide()); + +// connect if there are any LiveViews on the page +liveSocket.connect(); + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket; + diff --git a/priv/static/client/emoji-picker.css b/priv/static/client/emoji-picker.css new file mode 100644 index 0000000..09b48a7 --- /dev/null +++ b/priv/static/client/emoji-picker.css @@ -0,0 +1,30 @@ +.emoji-picker { + display: flex; + flex-direction: row; + align-items: flex-start; + + max-height: 80vh; +} + +.emoji-picker-content { + overflow-y: scroll; + align-self: stretch; +} + +.emoji-picker-categories button, +.emoji-picker-category button { + background-color: rgba(0, 0, 0, 0); + border: none; + padding: 5px; + + font-size: 2.5rem; +} + +.emoji-picker-categories { + background-color: #9b4dca; + border-radius: 5px; +} + +#emoji-picker-wrapper { + display: none; +} diff --git a/priv/static/client/emoji-picker.js b/priv/static/client/emoji-picker.js new file mode 100644 index 0000000..201309c --- /dev/null +++ b/priv/static/client/emoji-picker.js @@ -0,0 +1,14 @@ +export const emojiPickerHook = { + mounted() { + this.el.addEventListener("scroll", e => { + const targetId = e.detail?.id; + if (targetId) { + const el = document.getElementById(targetId); + + if (el) { + el.scrollIntoView(); + } + } + }); + } +}; diff --git a/priv/static/client/screenshot.css b/priv/static/client/screenshot.css new file mode 100644 index 0000000..3f27a9a --- /dev/null +++ b/priv/static/client/screenshot.css @@ -0,0 +1,3 @@ +.screenshot { + max-width: 100%; +} diff --git a/priv/static/client/screenshot.js b/priv/static/client/screenshot.js new file mode 100644 index 0000000..72bcd7d --- /dev/null +++ b/priv/static/client/screenshot.js @@ -0,0 +1,18 @@ +export const screenshotDownloadHook = { + mounted() { + this.el.addEventListener("click", () => { + const img = document.getElementById("screenshot-img"); + + if (img) { + // Don't you just love web programming?! + const a = document.createElement("a"); + a.href = img.getAttribute("src"); + a.target = "_blank"; + a.download = "screenshot.png"; + + const evt = new MouseEvent("click"); + a.dispatchEvent(evt); + } + }); + } +}; diff --git a/priv/static/common/common.css b/priv/static/common/common.css new file mode 100644 index 0000000..c4d03f8 --- /dev/null +++ b/priv/static/common/common.css @@ -0,0 +1,29 @@ +@import "./vendor/normalize.css"; +@import "./vendor/milligram.css"; + +@keyframes fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +html { + height: 100%; +} + +.fade-out { + opacity: 0; + animation-fill-mode: forwards; + animation: fade-out 1s linear 0s; +} + +button, +.button { + font-size: 3rem; + padding: 0 1rem; + height: initial; +} diff --git a/priv/static/common/lv.js b/priv/static/common/lv.js new file mode 100644 index 0000000..4865d24 --- /dev/null +++ b/priv/static/common/lv.js @@ -0,0 +1,4066 @@ +// js/phoenix_live_view/constants.js +var CONSECUTIVE_RELOADS = "consecutive-reloads"; +var MAX_RELOADS = 10; +var RELOAD_JITTER_MIN = 5e3; +var RELOAD_JITTER_MAX = 1e4; +var FAILSAFE_JITTER = 3e4; +var PHX_EVENT_CLASSES = [ + "phx-click-loading", + "phx-change-loading", + "phx-submit-loading", + "phx-keydown-loading", + "phx-keyup-loading", + "phx-blur-loading", + "phx-focus-loading" +]; +var PHX_COMPONENT = "data-phx-component"; +var PHX_LIVE_LINK = "data-phx-link"; +var PHX_TRACK_STATIC = "track-static"; +var PHX_LINK_STATE = "data-phx-link-state"; +var PHX_REF = "data-phx-ref"; +var PHX_REF_SRC = "data-phx-ref-src"; +var PHX_TRACK_UPLOADS = "track-uploads"; +var PHX_UPLOAD_REF = "data-phx-upload-ref"; +var PHX_PREFLIGHTED_REFS = "data-phx-preflighted-refs"; +var PHX_DONE_REFS = "data-phx-done-refs"; +var PHX_DROP_TARGET = "drop-target"; +var PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"; +var PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"; +var PHX_SKIP = "data-phx-skip"; +var PHX_PRUNE = "data-phx-prune"; +var PHX_PAGE_LOADING = "page-loading"; +var PHX_CONNECTED_CLASS = "phx-connected"; +var PHX_DISCONNECTED_CLASS = "phx-loading"; +var PHX_NO_FEEDBACK_CLASS = "phx-no-feedback"; +var PHX_ERROR_CLASS = "phx-error"; +var PHX_PARENT_ID = "data-phx-parent-id"; +var PHX_MAIN = "data-phx-main"; +var PHX_ROOT_ID = "data-phx-root-id"; +var PHX_TRIGGER_ACTION = "trigger-action"; +var PHX_FEEDBACK_FOR = "feedback-for"; +var PHX_HAS_FOCUSED = "phx-has-focused"; +var FOCUSABLE_INPUTS = ["text", "textarea", "number", "email", "password", "search", "tel", "url", "date", "time", "datetime-local", "color", "range"]; +var CHECKABLE_INPUTS = ["checkbox", "radio"]; +var PHX_HAS_SUBMITTED = "phx-has-submitted"; +var PHX_SESSION = "data-phx-session"; +var PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`; +var PHX_STICKY = "data-phx-sticky"; +var PHX_STATIC = "data-phx-static"; +var PHX_READONLY = "data-phx-readonly"; +var PHX_DISABLED = "data-phx-disabled"; +var PHX_DISABLE_WITH = "disable-with"; +var PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore"; +var PHX_HOOK = "hook"; +var PHX_DEBOUNCE = "debounce"; +var PHX_THROTTLE = "throttle"; +var PHX_UPDATE = "update"; +var PHX_KEY = "key"; +var PHX_PRIVATE = "phxPrivate"; +var PHX_AUTO_RECOVER = "auto-recover"; +var PHX_LV_DEBUG = "phx:live-socket:debug"; +var PHX_LV_PROFILE = "phx:live-socket:profiling"; +var PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim"; +var PHX_PROGRESS = "progress"; +var PHX_MOUNTED = "mounted"; +var LOADER_TIMEOUT = 1; +var BEFORE_UNLOAD_LOADER_TIMEOUT = 200; +var BINDING_PREFIX = "phx-"; +var PUSH_TIMEOUT = 3e4; +var DEBOUNCE_TRIGGER = "debounce-trigger"; +var THROTTLED = "throttled"; +var DEBOUNCE_PREV_KEY = "debounce-prev-key"; +var DEFAULTS = { + debounce: 300, + throttle: 300 +}; +var DYNAMICS = "d"; +var STATIC = "s"; +var COMPONENTS = "c"; +var EVENTS = "e"; +var REPLY = "r"; +var TITLE = "t"; +var TEMPLATES = "p"; + +// js/phoenix_live_view/entry_uploader.js +var EntryUploader = class { + constructor(entry, chunkSize, liveSocket) { + this.liveSocket = liveSocket; + this.entry = entry; + this.offset = 0; + this.chunkSize = chunkSize; + this.chunkTimer = null; + this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, { token: entry.metadata() }); + } + error(reason) { + clearTimeout(this.chunkTimer); + this.uploadChannel.leave(); + this.entry.error(reason); + } + upload() { + this.uploadChannel.onError((reason) => this.error(reason)); + this.uploadChannel.join().receive("ok", (_data) => this.readNextChunk()).receive("error", (reason) => this.error(reason)); + } + isDone() { + return this.offset >= this.entry.file.size; + } + readNextChunk() { + let reader = new window.FileReader(); + let blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset); + reader.onload = (e) => { + if (e.target.error === null) { + this.offset += e.target.result.byteLength; + this.pushChunk(e.target.result); + } else { + return logError("Read error: " + e.target.error); + } + }; + reader.readAsArrayBuffer(blob); + } + pushChunk(chunk) { + if (!this.uploadChannel.isJoined()) { + return; + } + this.uploadChannel.push("chunk", chunk).receive("ok", () => { + this.entry.progress(this.offset / this.entry.file.size * 100); + if (!this.isDone()) { + this.chunkTimer = setTimeout(() => this.readNextChunk(), this.liveSocket.getLatencySim() || 0); + } + }); + } +}; + +// js/phoenix_live_view/utils.js +var logError = (msg, obj) => console.error && console.error(msg, obj); +var isCid = (cid) => { + let type = typeof cid; + return type === "number" || type === "string" && /^(0|[1-9]\d*)$/.test(cid); +}; +function detectDuplicateIds() { + let ids = new Set(); + let elems = document.querySelectorAll("*[id]"); + for (let i = 0, len = elems.length; i < len; i++) { + if (ids.has(elems[i].id)) { + console.error(`Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.`); + } else { + ids.add(elems[i].id); + } + } +} +var debug = (view, kind, msg, obj) => { + if (view.liveSocket.isDebugEnabled()) { + console.log(`${view.id} ${kind}: ${msg} - `, obj); + } +}; +var closure = (val) => typeof val === "function" ? val : function () { + return val; +}; +var clone = (obj) => { + return JSON.parse(JSON.stringify(obj)); +}; +var closestPhxBinding = (el, binding, borderEl) => { + do { + if (el.matches(`[${binding}]`) && !el.disabled) { + return el; + } + el = el.parentElement || el.parentNode; + } while (el !== null && el.nodeType === 1 && !(borderEl && borderEl.isSameNode(el) || el.matches(PHX_VIEW_SELECTOR))); + return null; +}; +var isObject = (obj) => { + return obj !== null && typeof obj === "object" && !(obj instanceof Array); +}; +var isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2); +var isEmpty = (obj) => { + for (let x in obj) { + return false; + } + return true; +}; +var maybe = (el, callback) => el && callback(el); +var channelUploader = function (entries, onError, resp, liveSocket) { + entries.forEach((entry) => { + let entryUploader = new EntryUploader(entry, resp.config.chunk_size, liveSocket); + entryUploader.upload(); + }); +}; + +// js/phoenix_live_view/browser.js +var Browser = { + canPushState() { + return typeof history.pushState !== "undefined"; + }, + dropLocal(localStorage, namespace, subkey) { + return localStorage.removeItem(this.localKey(namespace, subkey)); + }, + updateLocal(localStorage, namespace, subkey, initial, func) { + let current = this.getLocal(localStorage, namespace, subkey); + let key = this.localKey(namespace, subkey); + let newVal = current === null ? initial : func(current); + localStorage.setItem(key, JSON.stringify(newVal)); + return newVal; + }, + getLocal(localStorage, namespace, subkey) { + return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey))); + }, + updateCurrentState(callback) { + if (!this.canPushState()) { + return; + } + history.replaceState(callback(history.state || {}), "", window.location.href); + }, + pushState(kind, meta, to) { + if (this.canPushState()) { + if (to !== window.location.href) { + if (meta.type == "redirect" && meta.scroll) { + let currentState = history.state || {}; + currentState.scroll = meta.scroll; + history.replaceState(currentState, "", window.location.href); + } + delete meta.scroll; + history[kind + "State"](meta, "", to || null); + let hashEl = this.getHashTargetEl(window.location.hash); + if (hashEl) { + hashEl.scrollIntoView(); + } else if (meta.type === "redirect") { + window.scroll(0, 0); + } + } + } else { + this.redirect(to); + } + }, + setCookie(name, value) { + document.cookie = `${name}=${value}`; + }, + getCookie(name) { + return document.cookie.replace(new RegExp(`(?:(?:^|.*;s*)${name}s*=s*([^;]*).*$)|^.*$`), "$1"); + }, + redirect(toURL, flash) { + if (flash) { + Browser.setCookie("__phoenix_flash__", flash + "; max-age=60000; path=/"); + } + window.location = toURL; + }, + localKey(namespace, subkey) { + return `${namespace}-${subkey}`; + }, + getHashTargetEl(maybeHash) { + let hash = maybeHash.toString().substring(1); + if (hash === "") { + return; + } + return document.getElementById(hash) || document.querySelector(`a[name="${hash}"]`); + } +}; +var browser_default = Browser; + +// js/phoenix_live_view/dom.js +var DOM = { + byId(id) { + return document.getElementById(id) || logError(`no id found for ${id}`); + }, + removeClass(el, className) { + el.classList.remove(className); + if (el.classList.length === 0) { + el.removeAttribute("class"); + } + }, + all(node, query, callback) { + if (!node) { + return []; + } + let array = Array.from(node.querySelectorAll(query)); + return callback ? array.forEach(callback) : array; + }, + childNodeLength(html) { + let template = document.createElement("template"); + template.innerHTML = html; + return template.content.childElementCount; + }, + isUploadInput(el) { + return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null; + }, + findUploadInputs(node) { + return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`); + }, + findComponentNodeList(node, cid) { + return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node); + }, + isPhxDestroyed(node) { + return node.id && DOM.private(node, "destroyed") ? true : false; + }, + markPhxChildDestroyed(el) { + if (this.isPhxChild(el)) { + el.setAttribute(PHX_SESSION, ""); + } + this.putPrivate(el, "destroyed", true); + }, + findPhxChildrenInFragment(html, parentId) { + let template = document.createElement("template"); + template.innerHTML = html; + return this.findPhxChildren(template.content, parentId); + }, + isIgnored(el, phxUpdate) { + return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore"; + }, + isPhxUpdate(el, phxUpdate, updateTypes) { + return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0; + }, + findPhxSticky(el) { + return this.all(el, `[${PHX_STICKY}]`); + }, + findPhxChildren(el, parentId) { + return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`); + }, + findParentCIDs(node, cids) { + let initial = new Set(cids); + let parentCids = cids.reduce((acc, cid) => { + let selector = `[${PHX_COMPONENT}="${cid}"] [${PHX_COMPONENT}]`; + this.filterWithinSameLiveView(this.all(node, selector), node).map((el) => parseInt(el.getAttribute(PHX_COMPONENT))).forEach((childCID) => acc.delete(childCID)); + return acc; + }, initial); + return parentCids.size === 0 ? new Set(cids) : parentCids; + }, + filterWithinSameLiveView(nodes, parent) { + if (parent.querySelector(PHX_VIEW_SELECTOR)) { + return nodes.filter((el) => this.withinSameLiveView(el, parent)); + } else { + return nodes; + } + }, + withinSameLiveView(node, parent) { + while (node = node.parentNode) { + if (node.isSameNode(parent)) { + return true; + } + if (node.getAttribute(PHX_SESSION) !== null) { + return false; + } + } + }, + private(el, key) { + return el[PHX_PRIVATE] && el[PHX_PRIVATE][key]; + }, + deletePrivate(el, key) { + el[PHX_PRIVATE] && delete el[PHX_PRIVATE][key]; + }, + putPrivate(el, key, value) { + if (!el[PHX_PRIVATE]) { + el[PHX_PRIVATE] = {}; + } + el[PHX_PRIVATE][key] = value; + }, + updatePrivate(el, key, defaultVal, updateFunc) { + let existing = this.private(el, key); + if (existing === void 0) { + this.putPrivate(el, key, updateFunc(defaultVal)); + } else { + this.putPrivate(el, key, updateFunc(existing)); + } + }, + copyPrivates(target, source) { + if (source[PHX_PRIVATE]) { + target[PHX_PRIVATE] = source[PHX_PRIVATE]; + } + }, + putTitle(str) { + let titleEl = document.querySelector("title"); + let { prefix, suffix } = titleEl.dataset; + document.title = `${prefix || ""}${str}${suffix || ""}`; + }, + debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback) { + let debounce = el.getAttribute(phxDebounce); + let throttle = el.getAttribute(phxThrottle); + if (debounce === "") { + debounce = defaultDebounce; + } + if (throttle === "") { + throttle = defaultThrottle; + } + let value = debounce || throttle; + switch (value) { + case null: + return callback(); + case "blur": + if (this.once(el, "debounce-blur")) { + el.addEventListener("blur", () => callback()); + } + return; + default: + let timeout = parseInt(value); + let trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback(); + let currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger); + if (isNaN(timeout)) { + return logError(`invalid throttle/debounce value: ${value}`); + } + if (throttle) { + let newKeyDown = false; + if (event.type === "keydown") { + let prevKey = this.private(el, DEBOUNCE_PREV_KEY); + this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key); + newKeyDown = prevKey !== event.key; + } + if (!newKeyDown && this.private(el, THROTTLED)) { + return false; + } else { + callback(); + this.putPrivate(el, THROTTLED, true); + setTimeout(() => { + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER); + } + }, timeout); + } + } else { + setTimeout(() => { + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle); + } + }, timeout); + } + let form = el.form; + if (form && this.once(form, "bind-debounce")) { + form.addEventListener("submit", () => { + Array.from(new FormData(form).entries(), ([name]) => { + let input = form.querySelector(`[name="${name}"]`); + this.incCycle(input, DEBOUNCE_TRIGGER); + this.deletePrivate(input, THROTTLED); + }); + }); + } + if (this.once(el, "bind-debounce")) { + el.addEventListener("blur", () => this.triggerCycle(el, DEBOUNCE_TRIGGER)); + } + } + }, + triggerCycle(el, key, currentCycle) { + let [cycle, trigger] = this.private(el, key); + if (!currentCycle) { + currentCycle = cycle; + } + if (currentCycle === cycle) { + this.incCycle(el, key); + trigger(); + } + }, + once(el, key) { + if (this.private(el, key) === true) { + return false; + } + this.putPrivate(el, key, true); + return true; + }, + incCycle(el, key, trigger = function () { + }) { + let [currentCycle] = this.private(el, key) || [0, trigger]; + currentCycle++; + this.putPrivate(el, key, [currentCycle, trigger]); + return currentCycle; + }, + discardError(container, el, phxFeedbackFor) { + let field = el.getAttribute && el.getAttribute(phxFeedbackFor); + let input = field && container.querySelector(`[id="${field}"], [name="${field}"], [name="${field}[]"]`); + if (!input) { + return; + } + if (!(this.private(input, PHX_HAS_FOCUSED) || this.private(input.form, PHX_HAS_SUBMITTED))) { + el.classList.add(PHX_NO_FEEDBACK_CLASS); + } + }, + showError(inputEl, phxFeedbackFor) { + if (inputEl.id || inputEl.name) { + this.all(inputEl.form, `[${phxFeedbackFor}="${inputEl.id}"], [${phxFeedbackFor}="${inputEl.name}"]`, (el) => { + this.removeClass(el, PHX_NO_FEEDBACK_CLASS); + }); + } + }, + isPhxChild(node) { + return node.getAttribute && node.getAttribute(PHX_PARENT_ID); + }, + isPhxSticky(node) { + return node.getAttribute && node.getAttribute(PHX_STICKY) !== null; + }, + firstPhxChild(el) { + return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]; + }, + dispatchEvent(target, name, opts = {}) { + let bubbles = opts.bubbles === void 0 ? true : !!opts.bubbles; + let eventOpts = { bubbles, cancelable: true, detail: opts.detail || {} }; + let event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts); + target.dispatchEvent(event); + }, + cloneNode(node, html) { + if (typeof html === "undefined") { + return node.cloneNode(true); + } else { + let cloned = node.cloneNode(false); + cloned.innerHTML = html; + return cloned; + } + }, + mergeAttrs(target, source, opts = {}) { + let exclude = opts.exclude || []; + let isIgnored = opts.isIgnored; + let sourceAttrs = source.attributes; + for (let i = sourceAttrs.length - 1; i >= 0; i--) { + let name = sourceAttrs[i].name; + if (exclude.indexOf(name) < 0) { + target.setAttribute(name, source.getAttribute(name)); + } + } + let targetAttrs = target.attributes; + for (let i = targetAttrs.length - 1; i >= 0; i--) { + let name = targetAttrs[i].name; + if (isIgnored) { + if (name.startsWith("data-") && !source.hasAttribute(name)) { + target.removeAttribute(name); + } + } else { + if (!source.hasAttribute(name)) { + target.removeAttribute(name); + } + } + } + }, + mergeFocusedInput(target, source) { + if (!(target instanceof HTMLSelectElement)) { + DOM.mergeAttrs(target, source, { exclude: ["value"] }); + } + if (source.readOnly) { + target.setAttribute("readonly", true); + } else { + target.removeAttribute("readonly"); + } + }, + hasSelectionRange(el) { + return el.setSelectionRange && (el.type === "text" || el.type === "textarea"); + }, + restoreFocus(focused, selectionStart, selectionEnd) { + if (!DOM.isTextualInput(focused)) { + return; + } + let wasFocused = focused.matches(":focus"); + if (focused.readOnly) { + focused.blur(); + } + if (!wasFocused) { + focused.focus(); + } + if (this.hasSelectionRange(focused)) { + focused.setSelectionRange(selectionStart, selectionEnd); + } + }, + isFormInput(el) { + return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button"; + }, + syncAttrsToProps(el) { + if (el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0) { + el.checked = el.getAttribute("checked") !== null; + } + }, + isTextualInput(el) { + return FOCUSABLE_INPUTS.indexOf(el.type) >= 0; + }, + isNowTriggerFormExternal(el, phxTriggerExternal) { + return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null; + }, + syncPendingRef(fromEl, toEl, disableWith) { + let ref = fromEl.getAttribute(PHX_REF); + if (ref === null) { + return true; + } + let refSrc = fromEl.getAttribute(PHX_REF_SRC); + if (DOM.isFormInput(fromEl) || fromEl.getAttribute(disableWith) !== null) { + if (DOM.isUploadInput(fromEl)) { + DOM.mergeAttrs(fromEl, toEl, { isIgnored: true }); + } + DOM.putPrivate(fromEl, PHX_REF, toEl); + return false; + } else { + PHX_EVENT_CLASSES.forEach((className) => { + fromEl.classList.contains(className) && toEl.classList.add(className); + }); + toEl.setAttribute(PHX_REF, ref); + toEl.setAttribute(PHX_REF_SRC, refSrc); + return true; + } + }, + cleanChildNodes(container, phxUpdate) { + if (DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])) { + let toRemove = []; + container.childNodes.forEach((childNode) => { + if (!childNode.id) { + let isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === ""; + if (!isEmptyTextNode) { + logError(`only HTML element tags with an id are allowed inside containers with phx-update. + +removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" + +`); + } + toRemove.push(childNode); + } + }); + toRemove.forEach((childNode) => childNode.remove()); + } + }, + replaceRootContainer(container, tagName, attrs) { + let retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID]); + if (container.tagName.toLowerCase() === tagName.toLowerCase()) { + Array.from(container.attributes).filter((attr) => !retainedAttrs.has(attr.name.toLowerCase())).forEach((attr) => container.removeAttribute(attr.name)); + Object.keys(attrs).filter((name) => !retainedAttrs.has(name.toLowerCase())).forEach((attr) => container.setAttribute(attr, attrs[attr])); + return container; + } else { + let newContainer = document.createElement(tagName); + Object.keys(attrs).forEach((attr) => newContainer.setAttribute(attr, attrs[attr])); + retainedAttrs.forEach((attr) => newContainer.setAttribute(attr, container.getAttribute(attr))); + newContainer.innerHTML = container.innerHTML; + container.replaceWith(newContainer); + return newContainer; + } + }, + getSticky(el, name, defaultVal) { + let op = (DOM.private(el, "sticky") || []).find(([existingName]) => name === existingName); + if (op) { + let [_name, _op, stashedResult] = op; + return stashedResult; + } else { + return typeof defaultVal === "function" ? defaultVal() : defaultVal; + } + }, + deleteSticky(el, name) { + this.updatePrivate(el, "sticky", [], (ops) => { + return ops.filter(([existingName, _]) => existingName !== name); + }); + }, + putSticky(el, name, op) { + let stashedResult = op(el); + this.updatePrivate(el, "sticky", [], (ops) => { + let existingIndex = ops.findIndex(([existingName]) => name === existingName); + if (existingIndex >= 0) { + ops[existingIndex] = [name, op, stashedResult]; + } else { + ops.push([name, op, stashedResult]); + } + return ops; + }); + }, + applyStickyOperations(el) { + let ops = DOM.private(el, "sticky"); + if (!ops) { + return; + } + ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)); + } +}; +var dom_default = DOM; + +// js/phoenix_live_view/upload_entry.js +var UploadEntry = class { + static isActive(fileEl, file) { + let isNew = file._phxRef === void 0; + let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); + let isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; + return file.size > 0 && (isNew || isActive); + } + static isPreflighted(fileEl, file) { + let preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(","); + let isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; + return isPreflighted && this.isActive(fileEl, file); + } + constructor(fileEl, file, view) { + this.ref = LiveUploader.genFileRef(file); + this.fileEl = fileEl; + this.file = file; + this.view = view; + this.meta = null; + this._isCancelled = false; + this._isDone = false; + this._progress = 0; + this._lastProgressSent = -1; + this._onDone = function () { + }; + this._onElUpdated = this.onElUpdated.bind(this); + this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + } + metadata() { + return this.meta; + } + progress(progress) { + this._progress = Math.floor(progress); + if (this._progress > this._lastProgressSent) { + if (this._progress >= 100) { + this._progress = 100; + this._lastProgressSent = 100; + this._isDone = true; + this.view.pushFileProgress(this.fileEl, this.ref, 100, () => { + LiveUploader.untrackFile(this.fileEl, this.file); + this._onDone(); + }); + } else { + this._lastProgressSent = this._progress; + this.view.pushFileProgress(this.fileEl, this.ref, this._progress); + } + } + } + cancel() { + this._isCancelled = true; + this._isDone = true; + this._onDone(); + } + isDone() { + return this._isDone; + } + error(reason = "failed") { + this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + this.view.pushFileProgress(this.fileEl, this.ref, { error: reason }); + LiveUploader.clearFiles(this.fileEl); + } + onDone(callback) { + this._onDone = () => { + this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + callback(); + }; + } + onElUpdated() { + let activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); + if (activeRefs.indexOf(this.ref) === -1) { + this.cancel(); + } + } + toPreflightPayload() { + return { + last_modified: this.file.lastModified, + name: this.file.name, + relative_path: this.file.webkitRelativePath, + size: this.file.size, + type: this.file.type, + ref: this.ref + }; + } + uploader(uploaders) { + if (this.meta.uploader) { + let callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`); + return { name: this.meta.uploader, callback }; + } else { + return { name: "channel", callback: channelUploader }; + } + } + zipPostFlight(resp) { + this.meta = resp.entries[this.ref]; + if (!this.meta) { + logError(`no preflight upload response returned with ref ${this.ref}`, { input: this.fileEl, response: resp }); + } + } +}; + +// js/phoenix_live_view/live_uploader.js +var liveUploaderFileRef = 0; +var LiveUploader = class { + static genFileRef(file) { + let ref = file._phxRef; + if (ref !== void 0) { + return ref; + } else { + file._phxRef = (liveUploaderFileRef++).toString(); + return file._phxRef; + } + } + static getEntryDataURL(inputEl, ref, callback) { + let file = this.activeFiles(inputEl).find((file2) => this.genFileRef(file2) === ref); + callback(URL.createObjectURL(file)); + } + static hasUploadsInProgress(formEl) { + let active = 0; + dom_default.findUploadInputs(formEl).forEach((input) => { + if (input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)) { + active++; + } + }); + return active > 0; + } + static serializeUploads(inputEl) { + let files = this.activeFiles(inputEl); + let fileData = {}; + files.forEach((file) => { + let entry = { path: inputEl.name }; + let uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF); + fileData[uploadRef] = fileData[uploadRef] || []; + entry.ref = this.genFileRef(file); + entry.last_modified = file.lastModified; + entry.name = file.name || entry.ref; + entry.relative_path = file.webkitRelativePath; + entry.type = file.type; + entry.size = file.size; + fileData[uploadRef].push(entry); + }); + return fileData; + } + static clearFiles(inputEl) { + inputEl.value = null; + inputEl.removeAttribute(PHX_UPLOAD_REF); + dom_default.putPrivate(inputEl, "files", []); + } + static untrackFile(inputEl, file) { + dom_default.putPrivate(inputEl, "files", dom_default.private(inputEl, "files").filter((f) => !Object.is(f, file))); + } + static trackFiles(inputEl, files) { + if (inputEl.getAttribute("multiple") !== null) { + let newFiles = files.filter((file) => !this.activeFiles(inputEl).find((f) => Object.is(f, file))); + dom_default.putPrivate(inputEl, "files", this.activeFiles(inputEl).concat(newFiles)); + inputEl.value = null; + } else { + dom_default.putPrivate(inputEl, "files", files); + } + } + static activeFileInputs(formEl) { + let fileInputs = dom_default.findUploadInputs(formEl); + return Array.from(fileInputs).filter((el) => el.files && this.activeFiles(el).length > 0); + } + static activeFiles(input) { + return (dom_default.private(input, "files") || []).filter((f) => UploadEntry.isActive(input, f)); + } + static inputsAwaitingPreflight(formEl) { + let fileInputs = dom_default.findUploadInputs(formEl); + return Array.from(fileInputs).filter((input) => this.filesAwaitingPreflight(input).length > 0); + } + static filesAwaitingPreflight(input) { + return this.activeFiles(input).filter((f) => !UploadEntry.isPreflighted(input, f)); + } + constructor(inputEl, view, onComplete) { + this.view = view; + this.onComplete = onComplete; + this._entries = Array.from(LiveUploader.filesAwaitingPreflight(inputEl) || []).map((file) => new UploadEntry(inputEl, file, view)); + this.numEntriesInProgress = this._entries.length; + } + entries() { + return this._entries; + } + initAdapterUpload(resp, onError, liveSocket) { + this._entries = this._entries.map((entry) => { + entry.zipPostFlight(resp); + entry.onDone(() => { + this.numEntriesInProgress--; + if (this.numEntriesInProgress === 0) { + this.onComplete(); + } + }); + return entry; + }); + let groupedEntries = this._entries.reduce((acc, entry) => { + let { name, callback } = entry.uploader(liveSocket.uploaders); + acc[name] = acc[name] || { callback, entries: [] }; + acc[name].entries.push(entry); + return acc; + }, {}); + for (let name in groupedEntries) { + let { callback, entries } = groupedEntries[name]; + callback(entries, onError, resp, liveSocket); + } + } +}; + +// js/phoenix_live_view/aria.js +var ARIA = { + focusMain() { + let target = document.querySelector("main h1, main, h1"); + if (target) { + let origTabIndex = target.tabIndex; + target.tabIndex = -1; + target.focus(); + target.tabIndex = origTabIndex; + } + }, + anyOf(instance, classes) { + return classes.find((name) => instance instanceof name); + }, + isFocusable(el, interactiveOnly) { + return el instanceof HTMLAnchorElement && el.rel !== "ignore" || el instanceof HTMLAreaElement && el.href !== void 0 || !el.disabled && this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]) || el instanceof HTMLIFrameElement || (el.tabIndex > 0 || !interactiveOnly && el.tabIndex === 0 && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"); + }, + attemptFocus(el, interactiveOnly) { + if (this.isFocusable(el, interactiveOnly)) { + try { + el.focus(); + } catch (e) { + } + } + return !!document.activeElement && document.activeElement.isSameNode(el); + }, + focusFirstInteractive(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child, true) || this.focusFirstInteractive(child, true)) { + return true; + } + child = child.nextElementSibling; + } + }, + focusFirst(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusFirst(child)) { + return true; + } + child = child.nextElementSibling; + } + }, + focusLast(el) { + let child = el.lastElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusLast(child)) { + return true; + } + child = child.previousElementSibling; + } + } +}; +var aria_default = ARIA; + +// js/phoenix_live_view/hooks.js +var Hooks = { + LiveFileUpload: { + activeRefs() { + return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS); + }, + preflightedRefs() { + return this.el.getAttribute(PHX_PREFLIGHTED_REFS); + }, + mounted() { + this.preflightedWas = this.preflightedRefs(); + }, + updated() { + let newPreflights = this.preflightedRefs(); + if (this.preflightedWas !== newPreflights) { + this.preflightedWas = newPreflights; + if (newPreflights === "") { + this.__view.cancelSubmit(this.el.form); + } + } + if (this.activeRefs() === "") { + this.el.value = null; + } + this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED)); + } + }, + LiveImgPreview: { + mounted() { + this.ref = this.el.getAttribute("data-phx-entry-ref"); + this.inputEl = document.getElementById(this.el.getAttribute(PHX_UPLOAD_REF)); + LiveUploader.getEntryDataURL(this.inputEl, this.ref, (url) => { + this.url = url; + this.el.src = url; + }); + }, + destroyed() { + URL.revokeObjectURL(this.url); + } + }, + FocusWrap: { + mounted() { + this.focusStart = this.el.firstElementChild; + this.focusEnd = this.el.lastElementChild; + this.focusStart.addEventListener("focus", () => aria_default.focusLast(this.el)); + this.focusEnd.addEventListener("focus", () => aria_default.focusFirst(this.el)); + this.el.addEventListener("phx:show-end", () => this.el.focus()); + if (window.getComputedStyle(this.el).display !== "none") { + aria_default.focusFirst(this.el); + } + } + } +}; +var hooks_default = Hooks; + +// js/phoenix_live_view/dom_post_morph_restorer.js +var DOMPostMorphRestorer = class { + constructor(containerBefore, containerAfter, updateType) { + let idsBefore = new Set(); + let idsAfter = new Set([...containerAfter.children].map((child) => child.id)); + let elementsToModify = []; + Array.from(containerBefore.children).forEach((child) => { + if (child.id) { + idsBefore.add(child.id); + if (idsAfter.has(child.id)) { + let previousElementId = child.previousElementSibling && child.previousElementSibling.id; + elementsToModify.push({ elementId: child.id, previousElementId }); + } + } + }); + this.containerId = containerAfter.id; + this.updateType = updateType; + this.elementsToModify = elementsToModify; + this.elementIdsToAdd = [...idsAfter].filter((id) => !idsBefore.has(id)); + } + perform() { + let container = dom_default.byId(this.containerId); + this.elementsToModify.forEach((elementToModify) => { + if (elementToModify.previousElementId) { + maybe(document.getElementById(elementToModify.previousElementId), (previousElem) => { + maybe(document.getElementById(elementToModify.elementId), (elem) => { + let isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id; + if (!isInRightPlace) { + previousElem.insertAdjacentElement("afterend", elem); + } + }); + }); + } else { + maybe(document.getElementById(elementToModify.elementId), (elem) => { + let isInRightPlace = elem.previousElementSibling == null; + if (!isInRightPlace) { + container.insertAdjacentElement("afterbegin", elem); + } + }); + } + }); + if (this.updateType == "prepend") { + this.elementIdsToAdd.reverse().forEach((elemId) => { + maybe(document.getElementById(elemId), (elem) => container.insertAdjacentElement("afterbegin", elem)); + }); + } + } +}; + +// node_modules/morphdom/dist/morphdom-esm.js +var DOCUMENT_FRAGMENT_NODE = 11; +function morphAttrs(fromNode, toNode) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + return; + } + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + if (fromValue !== attrValue) { + if (attr.prefix === "xmlns") { + attrName = attr.name; + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + var fromNodeAttrs = fromNode.attributes; + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } +} +var range; +var NS_XHTML = "http://www.w3.org/1999/xhtml"; +var doc = typeof document === "undefined" ? void 0 : document; +var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); +var HAS_RANGE_SUPPORT = !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); +function createFragmentFromTemplate(str) { + var template = doc.createElement("template"); + template.innerHTML = str; + return template.content.childNodes[0]; +} +function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; +} +function createFragmentFromWrap(str) { + var fragment = doc.createElement("body"); + fragment.innerHTML = str; + return fragment.childNodes[0]; +} +function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + return createFragmentFromTemplate(str); + } else if (HAS_RANGE_SUPPORT) { + return createFragmentFromRange(str); + } + return createFragmentFromWrap(str); +} +function compareNodeNames(fromEl, toEl) { + var fromNodeName = fromEl.nodeName; + var toNodeName = toEl.nodeName; + var fromCodeStart, toCodeStart; + if (fromNodeName === toNodeName) { + return true; + } + fromCodeStart = fromNodeName.charCodeAt(0); + toCodeStart = toNodeName.charCodeAt(0); + if (fromCodeStart <= 90 && toCodeStart >= 97) { + return fromNodeName === toNodeName.toUpperCase(); + } else if (toCodeStart <= 90 && fromCodeStart >= 97) { + return toNodeName === fromNodeName.toUpperCase(); + } else { + return false; + } +} +function createElementNS(name, namespaceURI) { + return !namespaceURI || namespaceURI === NS_XHTML ? doc.createElement(name) : doc.createElementNS(namespaceURI, name); +} +function moveChildren(fromEl, toEl) { + var curChild = fromEl.firstChild; + while (curChild) { + var nextChild = curChild.nextSibling; + toEl.appendChild(curChild); + curChild = nextChild; + } + return toEl; +} +function syncBooleanAttrProp(fromEl, toEl, name) { + if (fromEl[name] !== toEl[name]) { + fromEl[name] = toEl[name]; + if (fromEl[name]) { + fromEl.setAttribute(name, ""); + } else { + fromEl.removeAttribute(name); + } + } +} +var specialElHandlers = { + OPTION: function (fromEl, toEl) { + var parentNode = fromEl.parentNode; + if (parentNode) { + var parentName = parentNode.nodeName.toUpperCase(); + if (parentName === "OPTGROUP") { + parentNode = parentNode.parentNode; + parentName = parentNode && parentNode.nodeName.toUpperCase(); + } + if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) { + if (fromEl.hasAttribute("selected") && !toEl.selected) { + fromEl.setAttribute("selected", "selected"); + fromEl.removeAttribute("selected"); + } + parentNode.selectedIndex = -1; + } + } + syncBooleanAttrProp(fromEl, toEl, "selected"); + }, + INPUT: function (fromEl, toEl) { + syncBooleanAttrProp(fromEl, toEl, "checked"); + syncBooleanAttrProp(fromEl, toEl, "disabled"); + if (fromEl.value !== toEl.value) { + fromEl.value = toEl.value; + } + if (!toEl.hasAttribute("value")) { + fromEl.removeAttribute("value"); + } + }, + TEXTAREA: function (fromEl, toEl) { + var newValue = toEl.value; + if (fromEl.value !== newValue) { + fromEl.value = newValue; + } + var firstChild = fromEl.firstChild; + if (firstChild) { + var oldValue = firstChild.nodeValue; + if (oldValue == newValue || !newValue && oldValue == fromEl.placeholder) { + return; + } + firstChild.nodeValue = newValue; + } + }, + SELECT: function (fromEl, toEl) { + if (!toEl.hasAttribute("multiple")) { + var selectedIndex = -1; + var i = 0; + var curChild = fromEl.firstChild; + var optgroup; + var nodeName; + while (curChild) { + nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); + if (nodeName === "OPTGROUP") { + optgroup = curChild; + curChild = optgroup.firstChild; + } else { + if (nodeName === "OPTION") { + if (curChild.hasAttribute("selected")) { + selectedIndex = i; + break; + } + i++; + } + curChild = curChild.nextSibling; + if (!curChild && optgroup) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } + } + fromEl.selectedIndex = selectedIndex; + } + } +}; +var ELEMENT_NODE = 1; +var DOCUMENT_FRAGMENT_NODE$1 = 11; +var TEXT_NODE = 3; +var COMMENT_NODE = 8; +function noop() { +} +function defaultGetNodeKey(node) { + if (node) { + return node.getAttribute && node.getAttribute("id") || node.id; + } +} +function morphdomFactory(morphAttrs2) { + return function morphdom2(fromNode, toNode, options) { + if (!options) { + options = {}; + } + if (typeof toNode === "string") { + if (fromNode.nodeName === "#document" || fromNode.nodeName === "HTML" || fromNode.nodeName === "BODY") { + var toNodeHtml = toNode; + toNode = doc.createElement("html"); + toNode.innerHTML = toNodeHtml; + } else { + toNode = toElement(toNode); + } + } + var getNodeKey = options.getNodeKey || defaultGetNodeKey; + var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; + var onNodeAdded = options.onNodeAdded || noop; + var onBeforeElUpdated = options.onBeforeElUpdated || noop; + var onElUpdated = options.onElUpdated || noop; + var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; + var onNodeDiscarded = options.onNodeDiscarded || noop; + var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; + var childrenOnly = options.childrenOnly === true; + var fromNodesLookup = Object.create(null); + var keyedRemovalList = []; + function addKeyedRemoval(key) { + keyedRemovalList.push(key); + } + function walkDiscardedChildNodes(node, skipKeyedNodes) { + if (node.nodeType === ELEMENT_NODE) { + var curChild = node.firstChild; + while (curChild) { + var key = void 0; + if (skipKeyedNodes && (key = getNodeKey(curChild))) { + addKeyedRemoval(key); + } else { + onNodeDiscarded(curChild); + if (curChild.firstChild) { + walkDiscardedChildNodes(curChild, skipKeyedNodes); + } + } + curChild = curChild.nextSibling; + } + } + } + function removeNode(node, parentNode, skipKeyedNodes) { + if (onBeforeNodeDiscarded(node) === false) { + return; + } + if (parentNode) { + parentNode.removeChild(node); + } + onNodeDiscarded(node); + walkDiscardedChildNodes(node, skipKeyedNodes); + } + function indexTree(node) { + if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + var curChild = node.firstChild; + while (curChild) { + var key = getNodeKey(curChild); + if (key) { + fromNodesLookup[key] = curChild; + } + indexTree(curChild); + curChild = curChild.nextSibling; + } + } + } + indexTree(fromNode); + function handleNodeAdded(el) { + onNodeAdded(el); + var curChild = el.firstChild; + while (curChild) { + var nextSibling = curChild.nextSibling; + var key = getNodeKey(curChild); + if (key) { + var unmatchedFromEl = fromNodesLookup[key]; + if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { + curChild.parentNode.replaceChild(unmatchedFromEl, curChild); + morphEl(unmatchedFromEl, curChild); + } else { + handleNodeAdded(curChild); + } + } else { + handleNodeAdded(curChild); + } + curChild = nextSibling; + } + } + function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { + while (curFromNodeChild) { + var fromNextSibling = curFromNodeChild.nextSibling; + if (curFromNodeKey = getNodeKey(curFromNodeChild)) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode(curFromNodeChild, fromEl, true); + } + curFromNodeChild = fromNextSibling; + } + } + function morphEl(fromEl, toEl, childrenOnly2) { + var toElKey = getNodeKey(toEl); + if (toElKey) { + delete fromNodesLookup[toElKey]; + } + if (!childrenOnly2) { + if (onBeforeElUpdated(fromEl, toEl) === false) { + return; + } + morphAttrs2(fromEl, toEl); + onElUpdated(fromEl); + if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { + return; + } + } + if (fromEl.nodeName !== "TEXTAREA") { + morphChildren(fromEl, toEl); + } else { + specialElHandlers.TEXTAREA(fromEl, toEl); + } + } + function morphChildren(fromEl, toEl) { + var curToNodeChild = toEl.firstChild; + var curFromNodeChild = fromEl.firstChild; + var curToNodeKey; + var curFromNodeKey; + var fromNextSibling; + var toNextSibling; + var matchingFromEl; + outer: + while (curToNodeChild) { + toNextSibling = curToNodeChild.nextSibling; + curToNodeKey = getNodeKey(curToNodeChild); + while (curFromNodeChild) { + fromNextSibling = curFromNodeChild.nextSibling; + if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + curFromNodeKey = getNodeKey(curFromNodeChild); + var curFromNodeType = curFromNodeChild.nodeType; + var isCompatible = void 0; + if (curFromNodeType === curToNodeChild.nodeType) { + if (curFromNodeType === ELEMENT_NODE) { + if (curToNodeKey) { + if (curToNodeKey !== curFromNodeKey) { + if (matchingFromEl = fromNodesLookup[curToNodeKey]) { + if (fromNextSibling === matchingFromEl) { + isCompatible = false; + } else { + fromEl.insertBefore(matchingFromEl, curFromNodeChild); + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode(curFromNodeChild, fromEl, true); + } + curFromNodeChild = matchingFromEl; + } + } else { + isCompatible = false; + } + } + } else if (curFromNodeKey) { + isCompatible = false; + } + isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); + if (isCompatible) { + morphEl(curFromNodeChild, curToNodeChild); + } + } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { + isCompatible = true; + if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { + curFromNodeChild.nodeValue = curToNodeChild.nodeValue; + } + } + } + if (isCompatible) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode(curFromNodeChild, fromEl, true); + } + curFromNodeChild = fromNextSibling; + } + if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) { + fromEl.appendChild(matchingFromEl); + morphEl(matchingFromEl, curToNodeChild); + } else { + var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); + if (onBeforeNodeAddedResult !== false) { + if (onBeforeNodeAddedResult) { + curToNodeChild = onBeforeNodeAddedResult; + } + if (curToNodeChild.actualize) { + curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); + } + fromEl.appendChild(curToNodeChild); + handleNodeAdded(curToNodeChild); + } + } + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + } + cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); + var specialElHandler = specialElHandlers[fromEl.nodeName]; + if (specialElHandler) { + specialElHandler(fromEl, toEl); + } + } + var morphedNode = fromNode; + var morphedNodeType = morphedNode.nodeType; + var toNodeType = toNode.nodeType; + if (!childrenOnly) { + if (morphedNodeType === ELEMENT_NODE) { + if (toNodeType === ELEMENT_NODE) { + if (!compareNodeNames(fromNode, toNode)) { + onNodeDiscarded(fromNode); + morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI)); + } + } else { + morphedNode = toNode; + } + } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { + if (toNodeType === morphedNodeType) { + if (morphedNode.nodeValue !== toNode.nodeValue) { + morphedNode.nodeValue = toNode.nodeValue; + } + return morphedNode; + } else { + morphedNode = toNode; + } + } + } + if (morphedNode === toNode) { + onNodeDiscarded(fromNode); + } else { + if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { + return; + } + morphEl(morphedNode, toNode, childrenOnly); + if (keyedRemovalList) { + for (var i = 0, len = keyedRemovalList.length; i < len; i++) { + var elToRemove = fromNodesLookup[keyedRemovalList[i]]; + if (elToRemove) { + removeNode(elToRemove, elToRemove.parentNode, false); + } + } + } + } + if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) { + if (morphedNode.actualize) { + morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc); + } + fromNode.parentNode.replaceChild(morphedNode, fromNode); + } + return morphedNode; + }; +} +var morphdom = morphdomFactory(morphAttrs); +var morphdom_esm_default = morphdom; + +// js/phoenix_live_view/dom_patch.js +var DOMPatch = class { + static patchEl(fromEl, toEl, activeElement) { + morphdom_esm_default(fromEl, toEl, { + childrenOnly: false, + onBeforeElUpdated: (fromEl2, toEl2) => { + if (activeElement && activeElement.isSameNode(fromEl2) && dom_default.isFormInput(fromEl2)) { + dom_default.mergeFocusedInput(fromEl2, toEl2); + return false; + } + } + }); + } + constructor(view, container, id, html, targetCID) { + this.view = view; + this.liveSocket = view.liveSocket; + this.container = container; + this.id = id; + this.rootID = view.root.id; + this.html = html; + this.targetCID = targetCID; + this.cidPatch = isCid(this.targetCID); + this.callbacks = { + beforeadded: [], + beforeupdated: [], + beforephxChildAdded: [], + afteradded: [], + afterupdated: [], + afterdiscarded: [], + afterphxChildAdded: [], + aftertransitionsDiscarded: [] + }; + } + before(kind, callback) { + this.callbacks[`before${kind}`].push(callback); + } + after(kind, callback) { + this.callbacks[`after${kind}`].push(callback); + } + trackBefore(kind, ...args) { + this.callbacks[`before${kind}`].forEach((callback) => callback(...args)); + } + trackAfter(kind, ...args) { + this.callbacks[`after${kind}`].forEach((callback) => callback(...args)); + } + markPrunableContentForRemoval() { + dom_default.all(this.container, "[phx-update=append] > *, [phx-update=prepend] > *", (el) => { + el.setAttribute(PHX_PRUNE, ""); + }); + } + perform() { + let { view, liveSocket, container, html } = this; + let targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container; + if (this.isCIDPatch() && !targetContainer) { + return; + } + let focused = liveSocket.getActiveElement(); + let { selectionStart, selectionEnd } = focused && dom_default.hasSelectionRange(focused) ? focused : {}; + let phxUpdate = liveSocket.binding(PHX_UPDATE); + let phxFeedbackFor = liveSocket.binding(PHX_FEEDBACK_FOR); + let disableWith = liveSocket.binding(PHX_DISABLE_WITH); + let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION); + let phxRemove = liveSocket.binding("remove"); + let added = []; + let updates = []; + let appendPrependUpdates = []; + let pendingRemoves = []; + let externalFormTriggered = null; + let diffHTML = liveSocket.time("premorph container prep", () => { + return this.buildDiffHTML(container, html, phxUpdate, targetContainer); + }); + this.trackBefore("added", container); + this.trackBefore("updated", container, container); + liveSocket.time("morphdom", () => { + morphdom_esm_default(targetContainer, diffHTML, { + childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null, + getNodeKey: (node) => { + return dom_default.isPhxDestroyed(node) ? null : node.id; + }, + onBeforeNodeAdded: (el) => { + this.trackBefore("added", el); + return el; + }, + onNodeAdded: (el) => { + if (el instanceof HTMLImageElement && el.srcset) { + el.srcset = el.srcset; + } else if (el instanceof HTMLVideoElement && el.autoplay) { + el.play(); + } + if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; + } + dom_default.discardError(targetContainer, el, phxFeedbackFor); + if (dom_default.isPhxChild(el) && view.ownsElement(el) || dom_default.isPhxSticky(el) && view.ownsElement(el.parentNode)) { + this.trackAfter("phxChildAdded", el); + } + added.push(el); + }, + onNodeDiscarded: (el) => { + if (dom_default.isPhxChild(el) || dom_default.isPhxSticky(el)) { + liveSocket.destroyViewByEl(el); + } + this.trackAfter("discarded", el); + }, + onBeforeNodeDiscarded: (el) => { + if (el.getAttribute && el.getAttribute(PHX_PRUNE) !== null) { + return true; + } + if (el.parentNode !== null && dom_default.isPhxUpdate(el.parentNode, phxUpdate, ["append", "prepend"]) && el.id) { + return false; + } + if (el.getAttribute && el.getAttribute(phxRemove)) { + pendingRemoves.push(el); + return false; + } + if (this.skipCIDSibling(el)) { + return false; + } + return true; + }, + onElUpdated: (el) => { + if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; + } + updates.push(el); + }, + onBeforeElUpdated: (fromEl, toEl) => { + dom_default.cleanChildNodes(toEl, phxUpdate); + if (this.skipCIDSibling(toEl)) { + return false; + } + if (dom_default.isPhxSticky(fromEl)) { + return false; + } + if (dom_default.isIgnored(fromEl, phxUpdate) || fromEl.form && fromEl.form.isSameNode(externalFormTriggered)) { + this.trackBefore("updated", fromEl, toEl); + dom_default.mergeAttrs(fromEl, toEl, { isIgnored: true }); + updates.push(fromEl); + dom_default.applyStickyOperations(fromEl); + return false; + } + if (fromEl.type === "number" && (fromEl.validity && fromEl.validity.badInput)) { + return false; + } + if (!dom_default.syncPendingRef(fromEl, toEl, disableWith)) { + if (dom_default.isUploadInput(fromEl)) { + this.trackBefore("updated", fromEl, toEl); + updates.push(fromEl); + } + dom_default.applyStickyOperations(fromEl); + return false; + } + if (dom_default.isPhxChild(toEl)) { + let prevSession = fromEl.getAttribute(PHX_SESSION); + dom_default.mergeAttrs(fromEl, toEl, { exclude: [PHX_STATIC] }); + if (prevSession !== "") { + fromEl.setAttribute(PHX_SESSION, prevSession); + } + fromEl.setAttribute(PHX_ROOT_ID, this.rootID); + dom_default.applyStickyOperations(fromEl); + return false; + } + dom_default.copyPrivates(toEl, fromEl); + dom_default.discardError(targetContainer, toEl, phxFeedbackFor); + let isFocusedFormEl = focused && fromEl.isSameNode(focused) && dom_default.isFormInput(fromEl); + if (isFocusedFormEl) { + this.trackBefore("updated", fromEl, toEl); + dom_default.mergeFocusedInput(fromEl, toEl); + dom_default.syncAttrsToProps(fromEl); + updates.push(fromEl); + dom_default.applyStickyOperations(fromEl); + return false; + } else { + if (dom_default.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])) { + appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate))); + } + dom_default.syncAttrsToProps(toEl); + dom_default.applyStickyOperations(toEl); + this.trackBefore("updated", fromEl, toEl); + return true; + } + } + }); + }); + if (liveSocket.isDebugEnabled()) { + detectDuplicateIds(); + } + if (appendPrependUpdates.length > 0) { + liveSocket.time("post-morph append/prepend restoration", () => { + appendPrependUpdates.forEach((update) => update.perform()); + }); + } + liveSocket.silenceEvents(() => dom_default.restoreFocus(focused, selectionStart, selectionEnd)); + dom_default.dispatchEvent(document, "phx:update"); + added.forEach((el) => this.trackAfter("added", el)); + updates.forEach((el) => this.trackAfter("updated", el)); + if (pendingRemoves.length > 0) { + liveSocket.transitionRemoves(pendingRemoves); + liveSocket.requestDOMUpdate(() => { + pendingRemoves.forEach((el) => { + let child = dom_default.firstPhxChild(el); + if (child) { + liveSocket.destroyViewByEl(child); + } + el.remove(); + }); + this.trackAfter("transitionsDiscarded", pendingRemoves); + }); + } + if (externalFormTriggered) { + liveSocket.disconnect(); + externalFormTriggered.submit(); + } + return true; + } + isCIDPatch() { + return this.cidPatch; + } + skipCIDSibling(el) { + return el.nodeType === Node.ELEMENT_NODE && el.getAttribute(PHX_SKIP) !== null; + } + targetCIDContainer(html) { + if (!this.isCIDPatch()) { + return; + } + let [first, ...rest] = dom_default.findComponentNodeList(this.container, this.targetCID); + if (rest.length === 0 && dom_default.childNodeLength(html) === 1) { + return first; + } else { + return first && first.parentNode; + } + } + buildDiffHTML(container, html, phxUpdate, targetContainer) { + let isCIDPatch = this.isCIDPatch(); + let isCIDWithSingleRoot = isCIDPatch && targetContainer.getAttribute(PHX_COMPONENT) === this.targetCID.toString(); + if (!isCIDPatch || isCIDWithSingleRoot) { + return html; + } else { + let diffContainer = null; + let template = document.createElement("template"); + diffContainer = dom_default.cloneNode(targetContainer); + let [firstComponent, ...rest] = dom_default.findComponentNodeList(diffContainer, this.targetCID); + template.innerHTML = html; + rest.forEach((el) => el.remove()); + Array.from(diffContainer.childNodes).forEach((child) => { + if (child.id && child.nodeType === Node.ELEMENT_NODE && child.getAttribute(PHX_COMPONENT) !== this.targetCID.toString()) { + child.setAttribute(PHX_SKIP, ""); + child.innerHTML = ""; + } + }); + Array.from(template.content.childNodes).forEach((el) => diffContainer.insertBefore(el, firstComponent)); + firstComponent.remove(); + return diffContainer.outerHTML; + } + } +}; + +// js/phoenix_live_view/rendered.js +var Rendered = class { + static extract(diff) { + let { [REPLY]: reply, [EVENTS]: events, [TITLE]: title } = diff; + delete diff[REPLY]; + delete diff[EVENTS]; + delete diff[TITLE]; + return { diff, title, reply: reply || null, events: events || [] }; + } + constructor(viewId, rendered) { + this.viewId = viewId; + this.rendered = {}; + this.mergeDiff(rendered); + } + parentViewId() { + return this.viewId; + } + toString(onlyCids) { + return this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids); + } + recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids) { + onlyCids = onlyCids ? new Set(onlyCids) : null; + let output = { buffer: "", components, onlyCids }; + this.toOutputBuffer(rendered, null, output); + return output.buffer; + } + componentCIDs(diff) { + return Object.keys(diff[COMPONENTS] || {}).map((i) => parseInt(i)); + } + isComponentOnlyDiff(diff) { + if (!diff[COMPONENTS]) { + return false; + } + return Object.keys(diff).length === 1; + } + getComponent(diff, cid) { + return diff[COMPONENTS][cid]; + } + mergeDiff(diff) { + let newc = diff[COMPONENTS]; + let cache = {}; + delete diff[COMPONENTS]; + this.rendered = this.mutableMerge(this.rendered, diff); + this.rendered[COMPONENTS] = this.rendered[COMPONENTS] || {}; + if (newc) { + let oldc = this.rendered[COMPONENTS]; + for (let cid in newc) { + newc[cid] = this.cachedFindComponent(cid, newc[cid], oldc, newc, cache); + } + for (let cid in newc) { + oldc[cid] = newc[cid]; + } + diff[COMPONENTS] = newc; + } + } + cachedFindComponent(cid, cdiff, oldc, newc, cache) { + if (cache[cid]) { + return cache[cid]; + } else { + let ndiff, stat, scid = cdiff[STATIC]; + if (isCid(scid)) { + let tdiff; + if (scid > 0) { + tdiff = this.cachedFindComponent(scid, newc[scid], oldc, newc, cache); + } else { + tdiff = oldc[-scid]; + } + stat = tdiff[STATIC]; + ndiff = this.cloneMerge(tdiff, cdiff); + ndiff[STATIC] = stat; + } else { + ndiff = cdiff[STATIC] !== void 0 ? cdiff : this.cloneMerge(oldc[cid] || {}, cdiff); + } + cache[cid] = ndiff; + return ndiff; + } + } + mutableMerge(target, source) { + if (source[STATIC] !== void 0) { + return source; + } else { + this.doMutableMerge(target, source); + return target; + } + } + doMutableMerge(target, source) { + for (let key in source) { + let val = source[key]; + let targetVal = target[key]; + if (isObject(val) && val[STATIC] === void 0 && isObject(targetVal)) { + this.doMutableMerge(targetVal, val); + } else { + target[key] = val; + } + } + } + cloneMerge(target, source) { + let merged = { ...target, ...source }; + for (let key in merged) { + let val = source[key]; + let targetVal = target[key]; + if (isObject(val) && val[STATIC] === void 0 && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, val); + } + } + return merged; + } + componentToString(cid) { + return this.recursiveCIDToString(this.rendered[COMPONENTS], cid); + } + pruneCIDs(cids) { + cids.forEach((cid) => delete this.rendered[COMPONENTS][cid]); + } + get() { + return this.rendered; + } + isNewFingerprint(diff = {}) { + return !!diff[STATIC]; + } + templateStatic(part, templates) { + if (typeof part === "number") { + return templates[part]; + } else { + return part; + } + } + toOutputBuffer(rendered, templates, output) { + if (rendered[DYNAMICS]) { + return this.comprehensionToBuffer(rendered, templates, output); + } + let { [STATIC]: statics } = rendered; + statics = this.templateStatic(statics, templates); + output.buffer += statics[0]; + for (let i = 1; i < statics.length; i++) { + this.dynamicToBuffer(rendered[i - 1], templates, output); + output.buffer += statics[i]; + } + } + comprehensionToBuffer(rendered, templates, output) { + let { [DYNAMICS]: dynamics, [STATIC]: statics } = rendered; + statics = this.templateStatic(statics, templates); + let compTemplates = templates || rendered[TEMPLATES]; + for (let d = 0; d < dynamics.length; d++) { + let dynamic = dynamics[d]; + output.buffer += statics[0]; + for (let i = 1; i < statics.length; i++) { + this.dynamicToBuffer(dynamic[i - 1], compTemplates, output); + output.buffer += statics[i]; + } + } + } + dynamicToBuffer(rendered, templates, output) { + if (typeof rendered === "number") { + output.buffer += this.recursiveCIDToString(output.components, rendered, output.onlyCids); + } else if (isObject(rendered)) { + this.toOutputBuffer(rendered, templates, output); + } else { + output.buffer += rendered; + } + } + recursiveCIDToString(components, cid, onlyCids) { + let component = components[cid] || logError(`no component for CID ${cid}`, components); + let template = document.createElement("template"); + template.innerHTML = this.recursiveToString(component, components, onlyCids); + let container = template.content; + let skip = onlyCids && !onlyCids.has(cid); + let [hasChildNodes, hasChildComponents] = Array.from(container.childNodes).reduce(([hasNodes, hasComponents], child, i) => { + if (child.nodeType === Node.ELEMENT_NODE) { + if (child.getAttribute(PHX_COMPONENT)) { + return [hasNodes, true]; + } + child.setAttribute(PHX_COMPONENT, cid); + if (!child.id) { + child.id = `${this.parentViewId()}-${cid}-${i}`; + } + if (skip) { + child.setAttribute(PHX_SKIP, ""); + child.innerHTML = ""; + } + return [true, hasComponents]; + } else { + if (child.nodeValue.trim() !== "") { + logError(`only HTML element tags are allowed at the root of components. + +got: "${child.nodeValue.trim()}" + +within: +`, template.innerHTML.trim()); + child.replaceWith(this.createSpan(child.nodeValue, cid)); + return [true, hasComponents]; + } else { + child.remove(); + return [hasNodes, hasComponents]; + } + } + }, [false, false]); + if (!hasChildNodes && !hasChildComponents) { + logError("expected at least one HTML element tag inside a component, but the component is empty:\n", template.innerHTML.trim()); + return this.createSpan("", cid).outerHTML; + } else if (!hasChildNodes && hasChildComponents) { + logError("expected at least one HTML element tag directly inside a component, but only subcomponents were found. A component must render at least one HTML tag directly inside itself.", template.innerHTML.trim()); + return template.innerHTML; + } else { + return template.innerHTML; + } + } + createSpan(text, cid) { + let span = document.createElement("span"); + span.innerText = text; + span.setAttribute(PHX_COMPONENT, cid); + return span; + } +}; + +// js/phoenix_live_view/view_hook.js +var viewHookID = 1; +var ViewHook = class { + static makeID() { + return viewHookID++; + } + static elementID(el) { + return el.phxHookId; + } + constructor(view, el, callbacks) { + this.__view = view; + this.liveSocket = view.liveSocket; + this.__callbacks = callbacks; + this.__listeners = new Set(); + this.__isDisconnected = false; + this.el = el; + this.el.phxHookId = this.constructor.makeID(); + for (let key in this.__callbacks) { + this[key] = this.__callbacks[key]; + } + } + __mounted() { + this.mounted && this.mounted(); + } + __updated() { + this.updated && this.updated(); + } + __beforeUpdate() { + this.beforeUpdate && this.beforeUpdate(); + } + __destroyed() { + this.destroyed && this.destroyed(); + } + __reconnected() { + if (this.__isDisconnected) { + this.__isDisconnected = false; + this.reconnected && this.reconnected(); + } + } + __disconnected() { + this.__isDisconnected = true; + this.disconnected && this.disconnected(); + } + pushEvent(event, payload = {}, onReply = function () { + }) { + return this.__view.pushHookEvent(null, event, payload, onReply); + } + pushEventTo(phxTarget, event, payload = {}, onReply = function () { + }) { + return this.__view.withinTargets(phxTarget, (view, targetCtx) => { + return view.pushHookEvent(targetCtx, event, payload, onReply); + }); + } + handleEvent(event, callback) { + let callbackRef = (customEvent, bypass) => bypass ? event : callback(customEvent.detail); + window.addEventListener(`phx:${event}`, callbackRef); + this.__listeners.add(callbackRef); + return callbackRef; + } + removeHandleEvent(callbackRef) { + let event = callbackRef(null, true); + window.removeEventListener(`phx:${event}`, callbackRef); + this.__listeners.delete(callbackRef); + } + upload(name, files) { + return this.__view.dispatchUploads(name, files); + } + uploadTo(phxTarget, name, files) { + return this.__view.withinTargets(phxTarget, (view) => view.dispatchUploads(name, files)); + } + __cleanup__() { + this.__listeners.forEach((callbackRef) => this.removeHandleEvent(callbackRef)); + } +}; + +// js/phoenix_live_view/js.js +var focusStack = null; +var JS = { + exec(eventType, phxEvent, view, sourceEl, defaults) { + let [defaultKind, defaultArgs] = defaults || [null, {}]; + let commands = phxEvent.charAt(0) === "[" ? JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]; + commands.forEach(([kind, args]) => { + if (kind === defaultKind && defaultArgs.data) { + args.data = Object.assign(args.data || {}, defaultArgs.data); + } + this.filterToEls(sourceEl, args).forEach((el) => { + this[`exec_${kind}`](eventType, phxEvent, view, sourceEl, el, args); + }); + }); + }, + isVisible(el) { + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0); + }, + exec_dispatch(eventType, phxEvent, view, sourceEl, el, { to, event, detail, bubbles }) { + detail = detail || {}; + detail.dispatcher = sourceEl; + dom_default.dispatchEvent(el, event, { detail, bubbles }); + }, + exec_push(eventType, phxEvent, view, sourceEl, el, args) { + if (!view.isConnected()) { + return; + } + let { event, data, target, page_loading, loading, value, dispatcher } = args; + let pushOpts = { loading, value, target, page_loading: !!page_loading }; + let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl; + let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc; + view.withinTargets(phxTarget, (targetView, targetCtx) => { + if (eventType === "change") { + let { newCid, _target, callback } = args; + _target = _target || (sourceEl instanceof HTMLInputElement ? sourceEl.name : void 0); + if (_target) { + pushOpts._target = _target; + } + targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback); + } else if (eventType === "submit") { + targetView.submitForm(sourceEl, targetCtx, event || phxEvent, pushOpts); + } else { + targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts); + } + }); + }, + exec_navigate(eventType, phxEvent, view, sourceEl, el, { href, replace }) { + view.liveSocket.historyRedirect(href, replace ? "replace" : "push"); + }, + exec_patch(eventType, phxEvent, view, sourceEl, el, { href, replace }) { + view.liveSocket.pushHistoryPatch(href, replace ? "replace" : "push", sourceEl); + }, + exec_focus(eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => aria_default.attemptFocus(el)); + }, + exec_focus_first(eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => aria_default.focusFirstInteractive(el) || aria_default.focusFirst(el)); + }, + exec_push_focus(eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => focusStack = el || sourceEl); + }, + exec_pop_focus(eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => { + if (focusStack) { + focusStack.focus(); + } + focusStack = null; + }); + }, + exec_add_class(eventType, phxEvent, view, sourceEl, el, { names, transition, time }) { + this.addOrRemoveClasses(el, names, [], transition, time, view); + }, + exec_remove_class(eventType, phxEvent, view, sourceEl, el, { names, transition, time }) { + this.addOrRemoveClasses(el, [], names, transition, time, view); + }, + exec_transition(eventType, phxEvent, view, sourceEl, el, { time, transition }) { + let [transition_start, running, transition_end] = transition; + let onStart = () => this.addOrRemoveClasses(el, transition_start.concat(running), []); + let onDone = () => this.addOrRemoveClasses(el, transition_end, transition_start.concat(running)); + view.transition(time, onStart, onDone); + }, + exec_toggle(eventType, phxEvent, view, sourceEl, el, { display, ins, outs, time }) { + this.toggle(eventType, view, el, display, ins, outs, time); + }, + exec_show(eventType, phxEvent, view, sourceEl, el, { display, transition, time }) { + this.show(eventType, view, el, display, transition, time); + }, + exec_hide(eventType, phxEvent, view, sourceEl, el, { display, transition, time }) { + this.hide(eventType, view, el, display, transition, time); + }, + exec_set_attr(eventType, phxEvent, view, sourceEl, el, { attr: [attr, val] }) { + this.setOrRemoveAttrs(el, [[attr, val]], []); + }, + exec_remove_attr(eventType, phxEvent, view, sourceEl, el, { attr }) { + this.setOrRemoveAttrs(el, [], [attr]); + }, + show(eventType, view, el, display, transition, time) { + if (!this.isVisible(el)) { + this.toggle(eventType, view, el, display, transition, null, time); + } + }, + hide(eventType, view, el, display, transition, time) { + if (this.isVisible(el)) { + this.toggle(eventType, view, el, display, null, transition, time); + } + }, + toggle(eventType, view, el, display, ins, outs, time) { + let [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []]; + let [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []]; + if (inClasses.length > 0 || outClasses.length > 0) { + if (this.isVisible(el)) { + let onStart = () => { + this.addOrRemoveClasses(el, outStartClasses, inClasses.concat(inStartClasses).concat(inEndClasses)); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, outClasses, []); + window.requestAnimationFrame(() => this.addOrRemoveClasses(el, outEndClasses, outStartClasses)); + }); + }; + el.dispatchEvent(new Event("phx:hide-start")); + view.transition(time, onStart, () => { + this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)); + dom_default.putSticky(el, "toggle", (currentEl) => currentEl.style.display = "none"); + el.dispatchEvent(new Event("phx:hide-end")); + }); + } else { + if (eventType === "remove") { + return; + } + let onStart = () => { + this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses)); + dom_default.putSticky(el, "toggle", (currentEl) => currentEl.style.display = display || "block"); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, inClasses, []); + window.requestAnimationFrame(() => this.addOrRemoveClasses(el, inEndClasses, inStartClasses)); + }); + }; + el.dispatchEvent(new Event("phx:show-start")); + view.transition(time, onStart, () => { + this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses)); + el.dispatchEvent(new Event("phx:show-end")); + }); + } + } else { + if (this.isVisible(el)) { + window.requestAnimationFrame(() => { + el.dispatchEvent(new Event("phx:hide-start")); + dom_default.putSticky(el, "toggle", (currentEl) => currentEl.style.display = "none"); + el.dispatchEvent(new Event("phx:hide-end")); + }); + } else { + window.requestAnimationFrame(() => { + el.dispatchEvent(new Event("phx:show-start")); + dom_default.putSticky(el, "toggle", (currentEl) => currentEl.style.display = display || "block"); + el.dispatchEvent(new Event("phx:show-end")); + }); + } + } + }, + addOrRemoveClasses(el, adds, removes, transition, time, view) { + let [transition_run, transition_start, transition_end] = transition || [[], [], []]; + if (transition_run.length > 0) { + let onStart = () => this.addOrRemoveClasses(el, transition_start.concat(transition_run), []); + let onDone = () => this.addOrRemoveClasses(el, adds.concat(transition_end), removes.concat(transition_run).concat(transition_start)); + return view.transition(time, onStart, onDone); + } + window.requestAnimationFrame(() => { + let [prevAdds, prevRemoves] = dom_default.getSticky(el, "classes", [[], []]); + let keepAdds = adds.filter((name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)); + let keepRemoves = removes.filter((name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)); + let newAdds = prevAdds.filter((name) => removes.indexOf(name) < 0).concat(keepAdds); + let newRemoves = prevRemoves.filter((name) => adds.indexOf(name) < 0).concat(keepRemoves); + dom_default.putSticky(el, "classes", (currentEl) => { + currentEl.classList.remove(...newRemoves); + currentEl.classList.add(...newAdds); + return [newAdds, newRemoves]; + }); + }); + }, + setOrRemoveAttrs(el, sets, removes) { + let [prevSets, prevRemoves] = dom_default.getSticky(el, "attrs", [[], []]); + let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes); + let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets); + let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes); + dom_default.putSticky(el, "attrs", (currentEl) => { + newRemoves.forEach((attr) => currentEl.removeAttribute(attr)); + newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val)); + return [newSets, newRemoves]; + }); + }, + hasAllClasses(el, classes) { + return classes.every((name) => el.classList.contains(name)); + }, + isToggledOut(el, outClasses) { + return !this.isVisible(el) || this.hasAllClasses(el, outClasses); + }, + filterToEls(sourceEl, { to }) { + return to ? dom_default.all(document, to) : [sourceEl]; + } +}; +var js_default = JS; + +// js/phoenix_live_view/view.js +var serializeForm = (form, meta, onlyNames = []) => { + let formData = new FormData(form); + let toRemove = []; + formData.forEach((val, key, _index) => { + if (val instanceof File) { + toRemove.push(key); + } + }); + toRemove.forEach((key) => formData.delete(key)); + let params = new URLSearchParams(); + for (let [key, val] of formData.entries()) { + if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) { + params.append(key, val); + } + } + for (let metaKey in meta) { + params.append(metaKey, meta[metaKey]); + } + return params.toString(); +}; +var View = class { + constructor(el, liveSocket, parentView, flash, liveReferer) { + this.isDead = false; + this.liveSocket = liveSocket; + this.flash = flash; + this.parent = parentView; + this.root = parentView ? parentView.root : this; + this.el = el; + this.id = this.el.id; + this.ref = 0; + this.childJoins = 0; + this.loaderTimer = null; + this.pendingDiffs = []; + this.pruningCIDs = []; + this.redirect = false; + this.href = null; + this.joinCount = this.parent ? this.parent.joinCount - 1 : 0; + this.joinPending = true; + this.destroyed = false; + this.joinCallback = function (onDone) { + onDone && onDone(); + }; + this.stopCallback = function () { + }; + this.pendingJoinOps = this.parent ? null : []; + this.viewHooks = {}; + this.uploaders = {}; + this.formSubmits = []; + this.children = this.parent ? null : {}; + this.root.children[this.id] = {}; + this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { + return { + redirect: this.redirect ? this.href : void 0, + url: this.redirect ? void 0 : this.href || void 0, + params: this.connectParams(liveReferer), + session: this.getSession(), + static: this.getStatic(), + flash: this.flash + }; + }); + } + setHref(href) { + this.href = href; + } + setRedirect(href) { + this.redirect = true; + this.href = href; + } + isMain() { + return this.el.getAttribute(PHX_MAIN) !== null; + } + connectParams(liveReferer) { + let params = this.liveSocket.params(this.el); + let manifest = dom_default.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`).map((node) => node.src || node.href).filter((url) => typeof url === "string"); + if (manifest.length > 0) { + params["_track_static"] = manifest; + } + params["_mounts"] = this.joinCount; + params["_live_referer"] = liveReferer; + return params; + } + isConnected() { + return this.channel.canPush(); + } + getSession() { + return this.el.getAttribute(PHX_SESSION); + } + getStatic() { + let val = this.el.getAttribute(PHX_STATIC); + return val === "" ? null : val; + } + destroy(callback = function () { + }) { + this.destroyAllChildren(); + this.destroyed = true; + delete this.root.children[this.id]; + if (this.parent) { + delete this.root.children[this.parent.id][this.id]; + } + clearTimeout(this.loaderTimer); + let onFinished = () => { + callback(); + for (let id in this.viewHooks) { + this.destroyHook(this.viewHooks[id]); + } + }; + dom_default.markPhxChildDestroyed(this.el); + this.log("destroyed", () => ["the child has been removed from the parent"]); + this.channel.leave().receive("ok", onFinished).receive("error", onFinished).receive("timeout", onFinished); + } + setContainerClasses(...classes) { + this.el.classList.remove(PHX_CONNECTED_CLASS, PHX_DISCONNECTED_CLASS, PHX_ERROR_CLASS); + this.el.classList.add(...classes); + } + showLoader(timeout) { + clearTimeout(this.loaderTimer); + if (timeout) { + this.loaderTimer = setTimeout(() => this.showLoader(), timeout); + } else { + for (let id in this.viewHooks) { + this.viewHooks[id].__disconnected(); + } + this.setContainerClasses(PHX_DISCONNECTED_CLASS); + } + } + execAll(binding) { + dom_default.all(this.el, `[${binding}]`, (el) => this.liveSocket.execJS(el, el.getAttribute(binding))); + } + hideLoader() { + clearTimeout(this.loaderTimer); + this.setContainerClasses(PHX_CONNECTED_CLASS); + this.execAll(this.binding("connected")); + } + triggerReconnected() { + for (let id in this.viewHooks) { + this.viewHooks[id].__reconnected(); + } + } + log(kind, msgCallback) { + this.liveSocket.log(this, kind, msgCallback); + } + transition(time, onStart, onDone = function () { + }) { + this.liveSocket.transition(time, onStart, onDone); + } + withinTargets(phxTarget, callback) { + if (phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement) { + return this.liveSocket.owner(phxTarget, (view) => callback(view, phxTarget)); + } + if (isCid(phxTarget)) { + let targets = dom_default.findComponentNodeList(this.el, phxTarget); + if (targets.length === 0) { + logError(`no component found matching phx-target of ${phxTarget}`); + } else { + callback(this, parseInt(phxTarget)); + } + } else { + let targets = Array.from(document.querySelectorAll(phxTarget)); + if (targets.length === 0) { + logError(`nothing found matching the phx-target selector "${phxTarget}"`); + } + targets.forEach((target) => this.liveSocket.owner(target, (view) => callback(view, target))); + } + } + applyDiff(type, rawDiff, callback) { + this.log(type, () => ["", clone(rawDiff)]); + let { diff, reply, events, title } = Rendered.extract(rawDiff); + if (title) { + dom_default.putTitle(title); + } + callback({ diff, reply, events }); + } + onJoin(resp) { + let { rendered, container } = resp; + if (container) { + let [tag, attrs] = container; + this.el = dom_default.replaceRootContainer(this.el, tag, attrs); + } + this.childJoins = 0; + this.joinPending = true; + this.flash = null; + browser_default.dropLocal(this.liveSocket.localStorage, window.location.pathname, CONSECUTIVE_RELOADS); + this.applyDiff("mount", rendered, ({ diff, events }) => { + this.rendered = new Rendered(this.id, diff); + let html = this.renderContainer(null, "join"); + this.dropPendingRefs(); + let forms = this.formsForRecovery(html); + this.joinCount++; + if (forms.length > 0) { + forms.forEach(([form, newForm, newCid], i) => { + this.pushFormRecovery(form, newCid, (resp2) => { + if (i === forms.length - 1) { + this.onJoinComplete(resp2, html, events); + } + }); + }); + } else { + this.onJoinComplete(resp, html, events); + } + }); + } + dropPendingRefs() { + dom_default.all(document, `[${PHX_REF_SRC}="${this.id}"][${PHX_REF}]`, (el) => { + el.removeAttribute(PHX_REF); + el.removeAttribute(PHX_REF_SRC); + }); + } + onJoinComplete({ live_patch }, html, events) { + if (this.joinCount > 1 || this.parent && !this.parent.isJoinPending()) { + return this.applyJoinPatch(live_patch, html, events); + } + let newChildren = dom_default.findPhxChildrenInFragment(html, this.id).filter((toEl) => { + let fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`); + let phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC); + if (phxStatic) { + toEl.setAttribute(PHX_STATIC, phxStatic); + } + return this.joinChild(toEl); + }); + if (newChildren.length === 0) { + if (this.parent) { + this.root.pendingJoinOps.push([this, () => this.applyJoinPatch(live_patch, html, events)]); + this.parent.ackJoin(this); + } else { + this.onAllChildJoinsComplete(); + this.applyJoinPatch(live_patch, html, events); + } + } else { + this.root.pendingJoinOps.push([this, () => this.applyJoinPatch(live_patch, html, events)]); + } + } + attachTrueDocEl() { + this.el = dom_default.byId(this.id); + this.el.setAttribute(PHX_ROOT_ID, this.root.id); + } + execNewMounted() { + dom_default.all(this.el, `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, (hookEl) => { + this.maybeAddNewHook(hookEl); + }); + dom_default.all(this.el, `[${this.binding(PHX_MOUNTED)}]`, (el) => this.maybeMounted(el)); + } + applyJoinPatch(live_patch, html, events) { + this.attachTrueDocEl(); + let patch = new DOMPatch(this, this.el, this.id, html, null); + patch.markPrunableContentForRemoval(); + this.performPatch(patch, false); + this.joinNewChildren(); + this.execNewMounted(); + this.joinPending = false; + this.liveSocket.dispatchEvents(events); + this.applyPendingUpdates(); + if (live_patch) { + let { kind, to } = live_patch; + this.liveSocket.historyPatch(to, kind); + } + this.hideLoader(); + if (this.joinCount > 1) { + this.triggerReconnected(); + } + this.stopCallback(); + } + triggerBeforeUpdateHook(fromEl, toEl) { + this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]); + let hook = this.getHook(fromEl); + let isIgnored = hook && dom_default.isIgnored(fromEl, this.binding(PHX_UPDATE)); + if (hook && !fromEl.isEqualNode(toEl) && !(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset))) { + hook.__beforeUpdate(); + return hook; + } + } + maybeMounted(el) { + let phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)); + let hasBeenInvoked = phxMounted && dom_default.private(el, "mounted"); + if (phxMounted && !hasBeenInvoked) { + this.liveSocket.execJS(el, phxMounted); + dom_default.putPrivate(el, "mounted", true); + } + } + maybeAddNewHook(el, force) { + let newHook = this.addHook(el); + if (newHook) { + newHook.__mounted(); + } + } + performPatch(patch, pruneCids) { + let removedEls = []; + let phxChildrenAdded = false; + let updatedHookIds = new Set(); + patch.after("added", (el) => { + this.liveSocket.triggerDOM("onNodeAdded", [el]); + this.maybeAddNewHook(el); + if (el.getAttribute) { + this.maybeMounted(el); + } + }); + patch.after("phxChildAdded", (el) => { + if (dom_default.isPhxSticky(el)) { + this.liveSocket.joinRootViews(); + } else { + phxChildrenAdded = true; + } + }); + patch.before("updated", (fromEl, toEl) => { + let hook = this.triggerBeforeUpdateHook(fromEl, toEl); + if (hook) { + updatedHookIds.add(fromEl.id); + } + }); + patch.after("updated", (el) => { + if (updatedHookIds.has(el.id)) { + this.getHook(el).__updated(); + } + }); + patch.after("discarded", (el) => { + if (el.nodeType === Node.ELEMENT_NODE) { + removedEls.push(el); + } + }); + patch.after("transitionsDiscarded", (els) => this.afterElementsRemoved(els, pruneCids)); + patch.perform(); + this.afterElementsRemoved(removedEls, pruneCids); + return phxChildrenAdded; + } + afterElementsRemoved(elements, pruneCids) { + let destroyedCIDs = []; + elements.forEach((parent) => { + let components = dom_default.all(parent, `[${PHX_COMPONENT}]`); + let hooks = dom_default.all(parent, `[${this.binding(PHX_HOOK)}]`); + components.concat(parent).forEach((el) => { + let cid = this.componentID(el); + if (isCid(cid) && destroyedCIDs.indexOf(cid) === -1) { + destroyedCIDs.push(cid); + } + }); + hooks.concat(parent).forEach((hookEl) => { + let hook = this.getHook(hookEl); + hook && this.destroyHook(hook); + }); + }); + if (pruneCids) { + this.maybePushComponentsDestroyed(destroyedCIDs); + } + } + joinNewChildren() { + dom_default.findPhxChildren(this.el, this.id).forEach((el) => this.joinChild(el)); + } + getChildById(id) { + return this.root.children[this.id][id]; + } + getDescendentByEl(el) { + if (el.id === this.id) { + return this; + } else { + return this.children[el.getAttribute(PHX_PARENT_ID)][el.id]; + } + } + destroyDescendent(id) { + for (let parentId in this.root.children) { + for (let childId in this.root.children[parentId]) { + if (childId === id) { + return this.root.children[parentId][childId].destroy(); + } + } + } + } + joinChild(el) { + let child = this.getChildById(el.id); + if (!child) { + let view = new View(el, this.liveSocket, this); + this.root.children[this.id][view.id] = view; + view.join(); + this.childJoins++; + return true; + } + } + isJoinPending() { + return this.joinPending; + } + ackJoin(_child) { + this.childJoins--; + if (this.childJoins === 0) { + if (this.parent) { + this.parent.ackJoin(this); + } else { + this.onAllChildJoinsComplete(); + } + } + } + onAllChildJoinsComplete() { + this.joinCallback(() => { + this.pendingJoinOps.forEach(([view, op]) => { + if (!view.isDestroyed()) { + op(); + } + }); + this.pendingJoinOps = []; + }); + } + update(diff, events) { + if (this.isJoinPending() || this.liveSocket.hasPendingLink() && this.root.isMain()) { + return this.pendingDiffs.push({ diff, events }); + } + this.rendered.mergeDiff(diff); + let phxChildrenAdded = false; + if (this.rendered.isComponentOnlyDiff(diff)) { + this.liveSocket.time("component patch complete", () => { + let parentCids = dom_default.findParentCIDs(this.el, this.rendered.componentCIDs(diff)); + parentCids.forEach((parentCID) => { + if (this.componentPatch(this.rendered.getComponent(diff, parentCID), parentCID)) { + phxChildrenAdded = true; + } + }); + }); + } else if (!isEmpty(diff)) { + this.liveSocket.time("full patch complete", () => { + let html = this.renderContainer(diff, "update"); + let patch = new DOMPatch(this, this.el, this.id, html, null); + phxChildrenAdded = this.performPatch(patch, true); + }); + } + this.liveSocket.dispatchEvents(events); + if (phxChildrenAdded) { + this.joinNewChildren(); + } + } + renderContainer(diff, kind) { + return this.liveSocket.time(`toString diff (${kind})`, () => { + let tag = this.el.tagName; + let cids = diff ? this.rendered.componentCIDs(diff).concat(this.pruningCIDs) : null; + let html = this.rendered.toString(cids); + return `<${tag}>${html}`; + }); + } + componentPatch(diff, cid) { + if (isEmpty(diff)) + return false; + let html = this.rendered.componentToString(cid); + let patch = new DOMPatch(this, this.el, this.id, html, cid); + let childrenAdded = this.performPatch(patch, true); + return childrenAdded; + } + getHook(el) { + return this.viewHooks[ViewHook.elementID(el)]; + } + addHook(el) { + if (ViewHook.elementID(el) || !el.getAttribute) { + return; + } + let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)); + if (hookName && !this.ownsElement(el)) { + return; + } + let callbacks = this.liveSocket.getHookCallbacks(hookName); + if (callbacks) { + if (!el.id) { + logError(`no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el); + } + let hook = new ViewHook(this, el, callbacks); + this.viewHooks[ViewHook.elementID(hook.el)] = hook; + return hook; + } else if (hookName !== null) { + logError(`unknown hook found for "${hookName}"`, el); + } + } + destroyHook(hook) { + hook.__destroyed(); + hook.__cleanup__(); + delete this.viewHooks[ViewHook.elementID(hook.el)]; + } + applyPendingUpdates() { + this.pendingDiffs.forEach(({ diff, events }) => this.update(diff, events)); + this.pendingDiffs = []; + } + onChannel(event, cb) { + this.liveSocket.onChannel(this.channel, event, (resp) => { + if (this.isJoinPending()) { + this.root.pendingJoinOps.push([this, () => cb(resp)]); + } else { + this.liveSocket.requestDOMUpdate(() => cb(resp)); + } + }); + } + bindChannel() { + this.liveSocket.onChannel(this.channel, "diff", (rawDiff) => { + this.liveSocket.requestDOMUpdate(() => { + this.applyDiff("update", rawDiff, ({ diff, events }) => this.update(diff, events)); + }); + }); + this.onChannel("redirect", ({ to, flash }) => this.onRedirect({ to, flash })); + this.onChannel("live_patch", (redir) => this.onLivePatch(redir)); + this.onChannel("live_redirect", (redir) => this.onLiveRedirect(redir)); + this.channel.onError((reason) => this.onError(reason)); + this.channel.onClose((reason) => this.onClose(reason)); + } + destroyAllChildren() { + for (let id in this.root.children[this.id]) { + this.getChildById(id).destroy(); + } + } + onLiveRedirect(redir) { + let { to, kind, flash } = redir; + let url = this.expandURL(to); + this.liveSocket.historyRedirect(url, kind, flash); + } + onLivePatch(redir) { + let { to, kind } = redir; + this.href = this.expandURL(to); + this.liveSocket.historyPatch(to, kind); + } + expandURL(to) { + return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to; + } + onRedirect({ to, flash }) { + this.liveSocket.redirect(to, flash); + } + isDestroyed() { + return this.destroyed; + } + joinDead() { + this.isDead = true; + } + join(callback) { + this.showLoader(this.liveSocket.loaderTimeout); + this.bindChannel(); + if (this.isMain()) { + this.stopCallback = this.liveSocket.withPageLoading({ to: this.href, kind: "initial" }); + } + this.joinCallback = (onDone) => { + onDone = onDone || function () { + }; + callback ? callback(this.joinCount, onDone) : onDone(); + }; + this.liveSocket.wrapPush(this, { timeout: false }, () => { + return this.channel.join().receive("ok", (data) => { + if (!this.isDestroyed()) { + this.liveSocket.requestDOMUpdate(() => this.onJoin(data)); + } + }).receive("error", (resp) => !this.isDestroyed() && this.onJoinError(resp)).receive("timeout", () => !this.isDestroyed() && this.onJoinError({ reason: "timeout" })); + }); + } + onJoinError(resp) { + if (resp.reason === "unauthorized" || resp.reason === "stale") { + this.log("error", () => ["unauthorized live_redirect. Falling back to page request", resp]); + return this.onRedirect({ to: this.href }); + } + if (resp.redirect || resp.live_redirect) { + this.joinPending = false; + this.channel.leave(); + } + if (resp.redirect) { + return this.onRedirect(resp.redirect); + } + if (resp.live_redirect) { + return this.onLiveRedirect(resp.live_redirect); + } + this.log("error", () => ["unable to join", resp]); + if (this.liveSocket.isConnected()) { + this.liveSocket.reloadWithJitter(this); + } + } + onClose(reason) { + if (this.isDestroyed()) { + return; + } + if (this.liveSocket.hasPendingLink() && reason !== "leave") { + return this.liveSocket.reloadWithJitter(this); + } + this.destroyAllChildren(); + this.liveSocket.dropActiveElement(this); + if (document.activeElement) { + document.activeElement.blur(); + } + if (this.liveSocket.isUnloaded()) { + this.showLoader(BEFORE_UNLOAD_LOADER_TIMEOUT); + } + } + onError(reason) { + this.onClose(reason); + if (this.liveSocket.isConnected()) { + this.log("error", () => ["view crashed", reason]); + } + if (!this.liveSocket.isUnloaded()) { + this.displayError(); + } + } + displayError() { + if (this.isMain()) { + dom_default.dispatchEvent(window, "phx:page-loading-start", { detail: { to: this.href, kind: "error" } }); + } + this.showLoader(); + this.setContainerClasses(PHX_DISCONNECTED_CLASS, PHX_ERROR_CLASS); + this.execAll(this.binding("disconnected")); + } + pushWithReply(refGenerator, event, payload, onReply = function () { + }) { + if (!this.isConnected()) { + return; + } + let [ref, [el], opts] = refGenerator ? refGenerator() : [null, [], {}]; + let onLoadingDone = function () { + }; + if (opts.page_loading || el && el.getAttribute(this.binding(PHX_PAGE_LOADING)) !== null) { + onLoadingDone = this.liveSocket.withPageLoading({ kind: "element", target: el }); + } + if (typeof payload.cid !== "number") { + delete payload.cid; + } + return this.liveSocket.wrapPush(this, { timeout: true }, () => { + return this.channel.push(event, payload, PUSH_TIMEOUT).receive("ok", (resp) => { + let finish = (hookReply) => { + if (resp.redirect) { + this.onRedirect(resp.redirect); + } + if (resp.live_patch) { + this.onLivePatch(resp.live_patch); + } + if (resp.live_redirect) { + this.onLiveRedirect(resp.live_redirect); + } + if (ref !== null) { + this.undoRefs(ref); + } + onLoadingDone(); + onReply(resp, hookReply); + }; + if (resp.diff) { + this.liveSocket.requestDOMUpdate(() => { + this.applyDiff("update", resp.diff, ({ diff, reply, events }) => { + this.update(diff, events); + finish(reply); + }); + }); + } else { + finish(null); + } + }); + }); + } + undoRefs(ref) { + if (!this.isConnected()) { + return; + } + dom_default.all(document, `[${PHX_REF_SRC}="${this.id}"][${PHX_REF}="${ref}"]`, (el) => { + let disabledVal = el.getAttribute(PHX_DISABLED); + el.removeAttribute(PHX_REF); + el.removeAttribute(PHX_REF_SRC); + if (el.getAttribute(PHX_READONLY) !== null) { + el.readOnly = false; + el.removeAttribute(PHX_READONLY); + } + if (disabledVal !== null) { + el.disabled = disabledVal === "true" ? true : false; + el.removeAttribute(PHX_DISABLED); + } + PHX_EVENT_CLASSES.forEach((className) => dom_default.removeClass(el, className)); + let disableRestore = el.getAttribute(PHX_DISABLE_WITH_RESTORE); + if (disableRestore !== null) { + el.innerText = disableRestore; + el.removeAttribute(PHX_DISABLE_WITH_RESTORE); + } + let toEl = dom_default.private(el, PHX_REF); + if (toEl) { + let hook = this.triggerBeforeUpdateHook(el, toEl); + DOMPatch.patchEl(el, toEl, this.liveSocket.getActiveElement()); + if (hook) { + hook.__updated(); + } + dom_default.deletePrivate(el, PHX_REF); + } + }); + } + putRef(elements, event, opts = {}) { + let newRef = this.ref++; + let disableWith = this.binding(PHX_DISABLE_WITH); + if (opts.loading) { + elements = elements.concat(dom_default.all(document, opts.loading)); + } + elements.forEach((el) => { + el.classList.add(`phx-${event}-loading`); + el.setAttribute(PHX_REF, newRef); + el.setAttribute(PHX_REF_SRC, this.el.id); + let disableText = el.getAttribute(disableWith); + if (disableText !== null) { + if (!el.getAttribute(PHX_DISABLE_WITH_RESTORE)) { + el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.innerText); + } + if (disableText !== "") { + el.innerText = disableText; + } + el.setAttribute("disabled", ""); + } + }); + return [newRef, elements, opts]; + } + componentID(el) { + let cid = el.getAttribute && el.getAttribute(PHX_COMPONENT); + return cid ? parseInt(cid) : null; + } + targetComponentID(target, targetCtx, opts = {}) { + if (isCid(targetCtx)) { + return targetCtx; + } + let cidOrSelector = target.getAttribute(this.binding("target")); + if (isCid(cidOrSelector)) { + return parseInt(cidOrSelector); + } else if (targetCtx && (cidOrSelector !== null || opts.target)) { + return this.closestComponentID(targetCtx); + } else { + return null; + } + } + closestComponentID(targetCtx) { + if (isCid(targetCtx)) { + return targetCtx; + } else if (targetCtx) { + return maybe(targetCtx.closest(`[${PHX_COMPONENT}]`), (el) => this.ownsElement(el) && this.componentID(el)); + } else { + return null; + } + } + pushHookEvent(targetCtx, event, payload, onReply) { + if (!this.isConnected()) { + this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload]); + return false; + } + let [ref, els, opts] = this.putRef([], "hook"); + this.pushWithReply(() => [ref, els, opts], "event", { + type: "hook", + event, + value: payload, + cid: this.closestComponentID(targetCtx) + }, (resp, reply) => onReply(reply, ref)); + return ref; + } + extractMeta(el, meta, value) { + let prefix = this.binding("value-"); + for (let i = 0; i < el.attributes.length; i++) { + if (!meta) { + meta = {}; + } + let name = el.attributes[i].name; + if (name.startsWith(prefix)) { + meta[name.replace(prefix, "")] = el.getAttribute(name); + } + } + if (el.value !== void 0) { + if (!meta) { + meta = {}; + } + meta.value = el.value; + if (el.tagName === "INPUT" && CHECKABLE_INPUTS.indexOf(el.type) >= 0 && !el.checked) { + delete meta.value; + } + } + if (value) { + if (!meta) { + meta = {}; + } + for (let key in value) { + meta[key] = value[key]; + } + } + return meta; + } + pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}) { + this.pushWithReply(() => this.putRef([el], type, opts), "event", { + type, + event: phxEvent, + value: this.extractMeta(el, meta, opts.value), + cid: this.targetComponentID(el, targetCtx, opts) + }); + } + pushFileProgress(fileEl, entryRef, progress, onReply = function () { + }) { + this.liveSocket.withinOwners(fileEl.form, (view, targetCtx) => { + view.pushWithReply(null, "progress", { + event: fileEl.getAttribute(view.binding(PHX_PROGRESS)), + ref: fileEl.getAttribute(PHX_UPLOAD_REF), + entry_ref: entryRef, + progress, + cid: view.targetComponentID(fileEl.form, targetCtx) + }, onReply); + }); + } + pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback) { + let uploads; + let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx); + let refGenerator = () => this.putRef([inputEl, inputEl.form], "change", opts); + let formData; + if (inputEl.getAttribute(this.binding("change"))) { + formData = serializeForm(inputEl.form, { _target: opts._target }, [inputEl.name]); + } else { + formData = serializeForm(inputEl.form, { _target: opts._target }); + } + if (dom_default.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0) { + LiveUploader.trackFiles(inputEl, Array.from(inputEl.files)); + } + uploads = LiveUploader.serializeUploads(inputEl); + let event = { + type: "form", + event: phxEvent, + value: formData, + uploads, + cid + }; + this.pushWithReply(refGenerator, "event", event, (resp) => { + dom_default.showError(inputEl, this.liveSocket.binding(PHX_FEEDBACK_FOR)); + if (dom_default.isUploadInput(inputEl) && inputEl.getAttribute("data-phx-auto-upload") !== null) { + if (LiveUploader.filesAwaitingPreflight(inputEl).length > 0) { + let [ref, _els] = refGenerator(); + this.uploadFiles(inputEl.form, targetCtx, ref, cid, (_uploads) => { + callback && callback(resp); + this.triggerAwaitingSubmit(inputEl.form); + }); + } + } else { + callback && callback(resp); + } + }); + } + triggerAwaitingSubmit(formEl) { + let awaitingSubmit = this.getScheduledSubmit(formEl); + if (awaitingSubmit) { + let [_el, _ref, _opts, callback] = awaitingSubmit; + this.cancelSubmit(formEl); + callback(); + } + } + getScheduledSubmit(formEl) { + return this.formSubmits.find(([el, _ref, _opts, _callback]) => el.isSameNode(formEl)); + } + scheduleSubmit(formEl, ref, opts, callback) { + if (this.getScheduledSubmit(formEl)) { + return true; + } + this.formSubmits.push([formEl, ref, opts, callback]); + } + cancelSubmit(formEl) { + this.formSubmits = this.formSubmits.filter(([el, ref, _callback]) => { + if (el.isSameNode(formEl)) { + this.undoRefs(ref); + return false; + } else { + return true; + } + }); + } + disableForm(formEl, opts = {}) { + let filterIgnored = (el) => { + let userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form); + return !(userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form)); + }; + let filterDisables = (el) => { + return el.hasAttribute(this.binding(PHX_DISABLE_WITH)); + }; + let filterButton = (el) => el.tagName == "BUTTON"; + let filterInput = (el) => ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName); + let formElements = Array.from(formEl.elements); + let disables = formElements.filter(filterDisables); + let buttons = formElements.filter(filterButton).filter(filterIgnored); + let inputs = formElements.filter(filterInput).filter(filterIgnored); + buttons.forEach((button) => { + button.setAttribute(PHX_DISABLED, button.disabled); + button.disabled = true; + }); + inputs.forEach((input) => { + input.setAttribute(PHX_READONLY, input.readOnly); + input.readOnly = true; + if (input.files) { + input.setAttribute(PHX_DISABLED, input.disabled); + input.disabled = true; + } + }); + formEl.setAttribute(this.binding(PHX_PAGE_LOADING), ""); + return this.putRef([formEl].concat(disables).concat(buttons).concat(inputs), "submit", opts); + } + pushFormSubmit(formEl, targetCtx, phxEvent, opts, onReply) { + let refGenerator = () => this.disableForm(formEl, opts); + let cid = this.targetComponentID(formEl, targetCtx); + if (LiveUploader.hasUploadsInProgress(formEl)) { + let [ref, _els] = refGenerator(); + let push = () => this.pushFormSubmit(formEl, targetCtx, phxEvent, opts, onReply); + return this.scheduleSubmit(formEl, ref, opts, push); + } else if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { + let [ref, els] = refGenerator(); + let proxyRefGen = () => [ref, els, opts]; + this.uploadFiles(formEl, targetCtx, ref, cid, (_uploads) => { + let formData = serializeForm(formEl, {}); + this.pushWithReply(proxyRefGen, "event", { + type: "form", + event: phxEvent, + value: formData, + cid + }, onReply); + }); + } else { + let formData = serializeForm(formEl, {}); + this.pushWithReply(refGenerator, "event", { + type: "form", + event: phxEvent, + value: formData, + cid + }, onReply); + } + } + uploadFiles(formEl, targetCtx, ref, cid, onComplete) { + let joinCountAtUpload = this.joinCount; + let inputEls = LiveUploader.activeFileInputs(formEl); + let numFileInputsInProgress = inputEls.length; + inputEls.forEach((inputEl) => { + let uploader = new LiveUploader(inputEl, this, () => { + numFileInputsInProgress--; + if (numFileInputsInProgress === 0) { + onComplete(); + } + }); + this.uploaders[inputEl] = uploader; + let entries = uploader.entries().map((entry) => entry.toPreflightPayload()); + let payload = { + ref: inputEl.getAttribute(PHX_UPLOAD_REF), + entries, + cid: this.targetComponentID(inputEl.form, targetCtx) + }; + this.log("upload", () => ["sending preflight request", payload]); + this.pushWithReply(null, "allow_upload", payload, (resp) => { + this.log("upload", () => ["got preflight response", resp]); + if (resp.error) { + this.undoRefs(ref); + let [entry_ref, reason] = resp.error; + this.log("upload", () => [`error for entry ${entry_ref}`, reason]); + } else { + let onError = (callback) => { + this.channel.onError(() => { + if (this.joinCount === joinCountAtUpload) { + callback(); + } + }); + }; + uploader.initAdapterUpload(resp, onError, this.liveSocket); + } + }); + }); + } + dispatchUploads(name, filesOrBlobs) { + let inputs = dom_default.findUploadInputs(this.el).filter((el) => el.name === name); + if (inputs.length === 0) { + logError(`no live file inputs found matching the name "${name}"`); + } else if (inputs.length > 1) { + logError(`duplicate live file inputs found matching the name "${name}"`); + } else { + dom_default.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, { detail: { files: filesOrBlobs } }); + } + } + pushFormRecovery(form, newCid, callback) { + this.liveSocket.withinOwners(form, (view, targetCtx) => { + let input = form.elements[0]; + let phxEvent = form.getAttribute(this.binding(PHX_AUTO_RECOVER)) || form.getAttribute(this.binding("change")); + js_default.exec("change", phxEvent, view, input, ["push", { _target: input.name, newCid, callback }]); + }); + } + pushLinkPatch(href, targetEl, callback) { + let linkRef = this.liveSocket.setPendingLink(href); + let refGen = targetEl ? () => this.putRef([targetEl], "click") : null; + let fallback = () => this.liveSocket.redirect(window.location.href); + let push = this.pushWithReply(refGen, "live_patch", { url: href }, (resp) => { + this.liveSocket.requestDOMUpdate(() => { + if (resp.link_redirect) { + this.liveSocket.replaceMain(href, null, callback, linkRef); + } else { + if (this.liveSocket.commitPendingLink(linkRef)) { + this.href = href; + } + this.applyPendingUpdates(); + callback && callback(linkRef); + } + }); + }); + if (push) { + push.receive("timeout", fallback); + } else { + fallback(); + } + } + formsForRecovery(html) { + if (this.joinCount === 0) { + return []; + } + let phxChange = this.binding("change"); + let template = document.createElement("template"); + template.innerHTML = html; + return dom_default.all(this.el, `form[${phxChange}]`).filter((form) => form.id && this.ownsElement(form)).filter((form) => form.elements.length > 0).filter((form) => form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore").map((form) => { + let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${form.getAttribute(phxChange)}"]`); + if (newForm) { + return [form, newForm, this.targetComponentID(newForm)]; + } else { + return [form, null, null]; + } + }).filter(([form, newForm, newCid]) => newForm); + } + maybePushComponentsDestroyed(destroyedCIDs) { + let willDestroyCIDs = destroyedCIDs.filter((cid) => { + return dom_default.findComponentNodeList(this.el, cid).length === 0; + }); + if (willDestroyCIDs.length > 0) { + this.pruningCIDs.push(...willDestroyCIDs); + this.pushWithReply(null, "cids_will_destroy", { cids: willDestroyCIDs }, () => { + this.pruningCIDs = this.pruningCIDs.filter((cid) => willDestroyCIDs.indexOf(cid) !== -1); + let completelyDestroyCIDs = willDestroyCIDs.filter((cid) => { + return dom_default.findComponentNodeList(this.el, cid).length === 0; + }); + if (completelyDestroyCIDs.length > 0) { + this.pushWithReply(null, "cids_destroyed", { cids: completelyDestroyCIDs }, (resp) => { + this.rendered.pruneCIDs(resp.cids); + }); + } + }); + } + } + ownsElement(el) { + return this.isDead || el.getAttribute(PHX_PARENT_ID) === this.id || maybe(el.closest(PHX_VIEW_SELECTOR), (node) => node.id) === this.id; + } + submitForm(form, targetCtx, phxEvent, opts = {}) { + dom_default.putPrivate(form, PHX_HAS_SUBMITTED, true); + let phxFeedback = this.liveSocket.binding(PHX_FEEDBACK_FOR); + let inputs = Array.from(form.elements); + this.liveSocket.blurActiveElement(this); + this.pushFormSubmit(form, targetCtx, phxEvent, opts, () => { + inputs.forEach((input) => dom_default.showError(input, phxFeedback)); + this.liveSocket.restorePreviouslyActiveFocus(); + }); + } + binding(kind) { + return this.liveSocket.binding(kind); + } +}; + +// js/phoenix_live_view/live_socket.js +var LiveSocket = class { + constructor(url, phxSocket, opts = {}) { + this.unloaded = false; + if (!phxSocket || phxSocket.constructor.name === "Object") { + throw new Error(` + a phoenix Socket must be provided as the second argument to the LiveSocket constructor. For example: + + import {Socket} from "phoenix" + import {LiveSocket} from "phoenix_live_view" + let liveSocket = new LiveSocket("/live", Socket, {...}) + `); + } + this.socket = new phxSocket(url, opts); + this.bindingPrefix = opts.bindingPrefix || BINDING_PREFIX; + this.opts = opts; + this.params = closure(opts.params || {}); + this.viewLogger = opts.viewLogger; + this.metadataCallbacks = opts.metadata || {}; + this.defaults = Object.assign(clone(DEFAULTS), opts.defaults || {}); + this.activeElement = null; + this.prevActive = null; + this.silenced = false; + this.main = null; + this.outgoingMainEl = null; + this.clickStartedAtTarget = null; + this.linkRef = 1; + this.roots = {}; + this.href = window.location.href; + this.pendingLink = null; + this.currentLocation = clone(window.location); + this.hooks = opts.hooks || {}; + this.uploaders = opts.uploaders || {}; + this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT; + this.reloadWithJitterTimer = null; + this.maxReloads = opts.maxReloads || MAX_RELOADS; + this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN; + this.reloadJitterMax = opts.reloadJitterMax || RELOAD_JITTER_MAX; + this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER; + this.localStorage = opts.localStorage || window.localStorage; + this.sessionStorage = opts.sessionStorage || window.sessionStorage; + this.boundTopLevelEvents = false; + this.domCallbacks = Object.assign({ onNodeAdded: closure(), onBeforeElUpdated: closure() }, opts.dom || {}); + this.transitions = new TransitionSet(); + window.addEventListener("pagehide", (_e) => { + this.unloaded = true; + }); + this.socket.onOpen(() => { + if (this.isUnloaded()) { + window.location.reload(); + } + }); + } + isProfileEnabled() { + return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true"; + } + isDebugEnabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true"; + } + isDebugDisabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false"; + } + enableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "true"); + } + enableProfiling() { + this.sessionStorage.setItem(PHX_LV_PROFILE, "true"); + } + disableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "false"); + } + disableProfiling() { + this.sessionStorage.removeItem(PHX_LV_PROFILE); + } + enableLatencySim(upperBoundMs) { + this.enableDebug(); + console.log("latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable"); + this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs); + } + disableLatencySim() { + this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM); + } + getLatencySim() { + let str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM); + return str ? parseInt(str) : null; + } + getSocket() { + return this.socket; + } + connect() { + if (window.location.hostname === "localhost" && !this.isDebugDisabled()) { + this.enableDebug(); + } + let doConnect = () => { + if (this.joinRootViews()) { + this.bindTopLevelEvents(); + this.socket.connect(); + } else if (this.main) { + this.socket.connect(); + } else { + this.joinDeadView(); + } + }; + if (["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0) { + doConnect(); + } else { + document.addEventListener("DOMContentLoaded", () => doConnect()); + } + } + disconnect(callback) { + clearTimeout(this.reloadWithJitterTimer); + this.socket.disconnect(callback); + } + replaceTransport(transport) { + clearTimeout(this.reloadWithJitterTimer); + this.socket.replaceTransport(transport); + this.connect(); + } + execJS(el, encodedJS, eventType = null) { + this.owner(el, (view) => js_default.exec(eventType, encodedJS, view, el)); + } + triggerDOM(kind, args) { + this.domCallbacks[kind](...args); + } + time(name, func) { + if (!this.isProfileEnabled() || !console.time) { + return func(); + } + console.time(name); + let result = func(); + console.timeEnd(name); + return result; + } + log(view, kind, msgCallback) { + if (this.viewLogger) { + let [msg, obj] = msgCallback(); + this.viewLogger(view, kind, msg, obj); + } else if (this.isDebugEnabled()) { + let [msg, obj] = msgCallback(); + debug(view, kind, msg, obj); + } + } + requestDOMUpdate(callback) { + this.transitions.after(callback); + } + transition(time, onStart, onDone = function () { + }) { + this.transitions.addTransition(time, onStart, onDone); + } + onChannel(channel, event, cb) { + channel.on(event, (data) => { + let latency = this.getLatencySim(); + if (!latency) { + cb(data); + } else { + setTimeout(() => cb(data), latency); + } + }); + } + wrapPush(view, opts, push) { + let latency = this.getLatencySim(); + let oldJoinCount = view.joinCount; + if (!latency) { + if (this.isConnected() && opts.timeout) { + return push().receive("timeout", () => { + if (view.joinCount === oldJoinCount && !view.isDestroyed()) { + this.reloadWithJitter(view, () => { + this.log(view, "timeout", () => ["received timeout while communicating with server. Falling back to hard refresh for recovery"]); + }); + } + }); + } else { + return push(); + } + } + let fakePush = { + receives: [], + receive(kind, cb) { + this.receives.push([kind, cb]); + } + }; + setTimeout(() => { + if (view.isDestroyed()) { + return; + } + fakePush.receives.reduce((acc, [kind, cb]) => acc.receive(kind, cb), push()); + }, latency); + return fakePush; + } + reloadWithJitter(view, log) { + clearTimeout(this.reloadWithJitterTimer); + this.disconnect(); + let minMs = this.reloadJitterMin; + let maxMs = this.reloadJitterMax; + let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; + let tries = browser_default.updateLocal(this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, (count) => count + 1); + if (tries > this.maxReloads) { + afterMs = this.failsafeJitter; + } + this.reloadWithJitterTimer = setTimeout(() => { + if (view.isDestroyed() || view.isConnected()) { + return; + } + view.destroy(); + log ? log() : this.log(view, "join", () => [`encountered ${tries} consecutive reloads`]); + if (tries > this.maxReloads) { + this.log(view, "join", () => [`exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode`]); + } + if (this.hasPendingLink()) { + window.location = this.pendingLink; + } else { + window.location.reload(); + } + }, afterMs); + } + getHookCallbacks(name) { + return name && name.startsWith("Phoenix.") ? hooks_default[name.split(".")[1]] : this.hooks[name]; + } + isUnloaded() { + return this.unloaded; + } + isConnected() { + return this.socket.isConnected(); + } + getBindingPrefix() { + return this.bindingPrefix; + } + binding(kind) { + return `${this.getBindingPrefix()}${kind}`; + } + channel(topic, params) { + return this.socket.channel(topic, params); + } + joinDeadView() { + this.bindTopLevelEvents({ dead: true }); + let view = this.newRootView(document.body); + view.setHref(this.getHref()); + view.joinDead(); + this.main = view; + window.requestAnimationFrame(() => view.execNewMounted()); + } + joinRootViews() { + let rootsFound = false; + dom_default.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, (rootEl) => { + if (!this.getRootById(rootEl.id)) { + let view = this.newRootView(rootEl); + view.setHref(this.getHref()); + view.join(); + if (rootEl.getAttribute(PHX_MAIN)) { + this.main = view; + } + } + rootsFound = true; + }); + return rootsFound; + } + redirect(to, flash) { + this.disconnect(); + browser_default.redirect(to, flash); + } + replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)) { + let liveReferer = this.currentLocation.href; + this.outgoingMainEl = this.outgoingMainEl || this.main.el; + let newMainEl = dom_default.cloneNode(this.outgoingMainEl, ""); + this.main.showLoader(this.loaderTimeout); + this.main.destroy(); + this.main = this.newRootView(newMainEl, flash, liveReferer); + this.main.setRedirect(href); + this.transitionRemoves(); + this.main.join((joinCount, onDone) => { + if (joinCount === 1 && this.commitPendingLink(linkRef)) { + this.requestDOMUpdate(() => { + dom_default.findPhxSticky(document).forEach((el) => newMainEl.appendChild(el)); + this.outgoingMainEl.replaceWith(newMainEl); + this.outgoingMainEl = null; + callback && requestAnimationFrame(callback); + onDone(); + }); + } + }); + } + transitionRemoves(elements) { + let removeAttr = this.binding("remove"); + elements = elements || dom_default.all(document, `[${removeAttr}]`); + elements.forEach((el) => { + if (document.body.contains(el)) { + this.execJS(el, el.getAttribute(removeAttr), "remove"); + } + }); + } + isPhxView(el) { + return el.getAttribute && el.getAttribute(PHX_SESSION) !== null; + } + newRootView(el, flash, liveReferer) { + let view = new View(el, this, null, flash, liveReferer); + this.roots[view.id] = view; + return view; + } + owner(childEl, callback) { + let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), (el) => this.getViewByEl(el)) || this.main; + if (view) { + callback(view); + } + } + withinOwners(childEl, callback) { + this.owner(childEl, (view) => callback(view, childEl)); + } + getViewByEl(el) { + let rootId = el.getAttribute(PHX_ROOT_ID); + return maybe(this.getRootById(rootId), (root) => root.getDescendentByEl(el)); + } + getRootById(id) { + return this.roots[id]; + } + destroyAllViews() { + for (let id in this.roots) { + this.roots[id].destroy(); + delete this.roots[id]; + } + this.main = null; + } + destroyViewByEl(el) { + let root = this.getRootById(el.getAttribute(PHX_ROOT_ID)); + if (root && root.id === el.id) { + root.destroy(); + delete this.roots[root.id]; + } else if (root) { + root.destroyDescendent(el.id); + } + } + setActiveElement(target) { + if (this.activeElement === target) { + return; + } + this.activeElement = target; + let cancel = () => { + if (target === this.activeElement) { + this.activeElement = null; + } + target.removeEventListener("mouseup", this); + target.removeEventListener("touchend", this); + }; + target.addEventListener("mouseup", cancel); + target.addEventListener("touchend", cancel); + } + getActiveElement() { + if (document.activeElement === document.body) { + return this.activeElement || document.activeElement; + } else { + return document.activeElement || document.body; + } + } + dropActiveElement(view) { + if (this.prevActive && view.ownsElement(this.prevActive)) { + this.prevActive = null; + } + } + restorePreviouslyActiveFocus() { + if (this.prevActive && this.prevActive !== document.body) { + this.prevActive.focus(); + } + } + blurActiveElement() { + this.prevActive = this.getActiveElement(); + if (this.prevActive !== document.body) { + this.prevActive.blur(); + } + } + bindTopLevelEvents({ dead } = {}) { + if (this.boundTopLevelEvents) { + return; + } + this.boundTopLevelEvents = true; + this.socket.onClose((event) => { + if (event && event.code === 1e3 && this.main) { + this.reloadWithJitter(this.main); + } + }); + document.body.addEventListener("click", function () { + }); + window.addEventListener("pageshow", (e) => { + if (e.persisted) { + this.getSocket().disconnect(); + this.withPageLoading({ to: window.location.href, kind: "redirect" }); + window.location.reload(); + } + }, true); + if (!dead) { + this.bindNav(); + } + this.bindClicks(); + if (!dead) { + this.bindForms(); + } + this.bind({ keyup: "keyup", keydown: "keydown" }, (e, type, view, targetEl, phxEvent, eventTarget) => { + let matchKey = targetEl.getAttribute(this.binding(PHX_KEY)); + let pressedKey = e.key && e.key.toLowerCase(); + if (matchKey && matchKey.toLowerCase() !== pressedKey) { + return; + } + let data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + js_default.exec(type, phxEvent, view, targetEl, ["push", { data }]); + }); + this.bind({ blur: "focusout", focus: "focusin" }, (e, type, view, targetEl, phxEvent, eventTarget) => { + if (!eventTarget) { + let data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + js_default.exec(type, phxEvent, view, targetEl, ["push", { data }]); + } + }); + this.bind({ blur: "blur", focus: "focus" }, (e, type, view, targetEl, targetCtx, phxEvent, phxTarget) => { + if (phxTarget === "window") { + let data = this.eventMeta(type, e, targetEl); + js_default.exec(type, phxEvent, view, targetEl, ["push", { data }]); + } + }); + window.addEventListener("dragover", (e) => e.preventDefault()); + window.addEventListener("drop", (e) => { + e.preventDefault(); + let dropTargetId = maybe(closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), (trueTarget) => { + return trueTarget.getAttribute(this.binding(PHX_DROP_TARGET)); + }); + let dropTarget = dropTargetId && document.getElementById(dropTargetId); + let files = Array.from(e.dataTransfer.files || []); + if (!dropTarget || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)) { + return; + } + LiveUploader.trackFiles(dropTarget, files); + dropTarget.dispatchEvent(new Event("input", { bubbles: true })); + }); + this.on(PHX_TRACK_UPLOADS, (e) => { + let uploadTarget = e.target; + if (!dom_default.isUploadInput(uploadTarget)) { + return; + } + let files = Array.from(e.detail.files || []).filter((f) => f instanceof File || f instanceof Blob); + LiveUploader.trackFiles(uploadTarget, files); + uploadTarget.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + eventMeta(eventName, e, targetEl) { + let callback = this.metadataCallbacks[eventName]; + return callback ? callback(e, targetEl) : {}; + } + setPendingLink(href) { + this.linkRef++; + this.pendingLink = href; + return this.linkRef; + } + commitPendingLink(linkRef) { + if (this.linkRef !== linkRef) { + return false; + } else { + this.href = this.pendingLink; + this.pendingLink = null; + return true; + } + } + getHref() { + return this.href; + } + hasPendingLink() { + return !!this.pendingLink; + } + bind(events, callback) { + for (let event in events) { + let browserEventName = events[event]; + this.on(browserEventName, (e) => { + let binding = this.binding(event); + let windowBinding = this.binding(`window-${event}`); + let targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding); + if (targetPhxEvent) { + this.debounce(e.target, e, browserEventName, () => { + this.withinOwners(e.target, (view) => { + callback(e, event, view, e.target, targetPhxEvent, null); + }); + }); + } else { + dom_default.all(document, `[${windowBinding}]`, (el) => { + let phxEvent = el.getAttribute(windowBinding); + this.debounce(el, e, browserEventName, () => { + this.withinOwners(el, (view) => { + callback(e, event, view, el, phxEvent, "window"); + }); + }); + }); + } + }); + } + } + bindClicks() { + window.addEventListener("click", (e) => this.clickStartedAtTarget = e.target); + this.bindClick("click", "click", false); + this.bindClick("mousedown", "capture-click", true); + } + bindClick(eventName, bindingName, capture) { + let click = this.binding(bindingName); + window.addEventListener(eventName, (e) => { + let target = null; + if (capture) { + target = e.target.matches(`[${click}]`) ? e.target : e.target.querySelector(`[${click}]`); + } else { + let clickStartedAtTarget = this.clickStartedAtTarget || e.target; + target = closestPhxBinding(clickStartedAtTarget, click); + this.dispatchClickAway(e, clickStartedAtTarget); + this.clickStartedAtTarget = null; + } + let phxEvent = target && target.getAttribute(click); + if (!phxEvent) { + return; + } + if (target.getAttribute("href") === "#") { + e.preventDefault(); + } + this.debounce(target, e, "click", () => { + this.withinOwners(target, (view) => { + js_default.exec("click", phxEvent, view, target, ["push", { data: this.eventMeta("click", e, target) }]); + }); + }); + }, capture); + } + dispatchClickAway(e, clickStartedAt) { + let phxClickAway = this.binding("click-away"); + dom_default.all(document, `[${phxClickAway}]`, (el) => { + if (!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))) { + this.withinOwners(e.target, (view) => { + let phxEvent = el.getAttribute(phxClickAway); + if (js_default.isVisible(el)) { + js_default.exec("click", phxEvent, view, el, ["push", { data: this.eventMeta("click", e, e.target) }]); + } + }); + } + }); + } + bindNav() { + if (!browser_default.canPushState()) { + return; + } + if (history.scrollRestoration) { + history.scrollRestoration = "manual"; + } + let scrollTimer = null; + window.addEventListener("scroll", (_e) => { + clearTimeout(scrollTimer); + scrollTimer = setTimeout(() => { + browser_default.updateCurrentState((state) => Object.assign(state, { scroll: window.scrollY })); + }, 100); + }); + window.addEventListener("popstate", (event) => { + if (!this.registerNewLocation(window.location)) { + return; + } + let { type, id, root, scroll } = event.state || {}; + let href = window.location.href; + this.requestDOMUpdate(() => { + if (this.main.isConnected() && (type === "patch" && id === this.main.id)) { + this.main.pushLinkPatch(href, null); + } else { + this.replaceMain(href, null, () => { + if (root) { + this.replaceRootHistory(); + } + if (typeof scroll === "number") { + setTimeout(() => { + window.scrollTo(0, scroll); + }, 0); + } + }); + } + }); + }, false); + window.addEventListener("click", (e) => { + let target = closestPhxBinding(e.target, PHX_LIVE_LINK); + let type = target && target.getAttribute(PHX_LIVE_LINK); + let wantsNewTab = e.metaKey || e.ctrlKey || e.button === 1; + if (!type || !this.isConnected() || !this.main || wantsNewTab) { + return; + } + let href = target.href; + let linkState = target.getAttribute(PHX_LINK_STATE); + e.preventDefault(); + e.stopImmediatePropagation(); + if (this.pendingLink === href) { + return; + } + this.requestDOMUpdate(() => { + if (type === "patch") { + this.pushHistoryPatch(href, linkState, target); + } else if (type === "redirect") { + this.historyRedirect(href, linkState); + } else { + throw new Error(`expected ${PHX_LIVE_LINK} to be "patch" or "redirect", got: ${type}`); + } + let phxClick = target.getAttribute(this.binding("click")); + if (phxClick) { + this.requestDOMUpdate(() => this.execJS(target, phxClick, "click")); + } + }); + }, false); + } + dispatchEvent(event, payload = {}) { + dom_default.dispatchEvent(window, `phx:${event}`, { detail: payload }); + } + dispatchEvents(events) { + events.forEach(([event, payload]) => this.dispatchEvent(event, payload)); + } + withPageLoading(info, callback) { + dom_default.dispatchEvent(window, "phx:page-loading-start", { detail: info }); + let done = () => dom_default.dispatchEvent(window, "phx:page-loading-stop", { detail: info }); + return callback ? callback(done) : done; + } + pushHistoryPatch(href, linkState, targetEl) { + if (!this.isConnected()) { + return browser_default.redirect(href); + } + this.withPageLoading({ to: href, kind: "patch" }, (done) => { + this.main.pushLinkPatch(href, targetEl, (linkRef) => { + this.historyPatch(href, linkState, linkRef); + done(); + }); + }); + } + historyPatch(href, linkState, linkRef = this.setPendingLink(href)) { + if (!this.commitPendingLink(linkRef)) { + return; + } + browser_default.pushState(linkState, { type: "patch", id: this.main.id }, href); + this.registerNewLocation(window.location); + } + historyRedirect(href, linkState, flash) { + if (!this.isConnected()) { + return browser_default.redirect(href, flash); + } + if (/^\/[^\/]+.*$/.test(href)) { + let { protocol, host } = window.location; + href = `${protocol}//${host}${href}`; + } + let scroll = window.scrollY; + this.withPageLoading({ to: href, kind: "redirect" }, (done) => { + this.replaceMain(href, flash, () => { + browser_default.pushState(linkState, { type: "redirect", id: this.main.id, scroll }, href); + this.registerNewLocation(window.location); + done(); + }); + }); + } + replaceRootHistory() { + browser_default.pushState("replace", { root: true, type: "patch", id: this.main.id }); + } + registerNewLocation(newLocation) { + let { pathname, search } = this.currentLocation; + if (pathname + search === newLocation.pathname + newLocation.search) { + return false; + } else { + this.currentLocation = clone(newLocation); + return true; + } + } + bindForms() { + let iterations = 0; + let externalFormSubmitted = false; + this.on("submit", (e) => { + let phxSubmit = e.target.getAttribute(this.binding("submit")); + let phxChange = e.target.getAttribute(this.binding("change")); + if (!externalFormSubmitted && phxChange && !phxSubmit) { + externalFormSubmitted = true; + e.preventDefault(); + this.withinOwners(e.target, (view) => { + view.disableForm(e.target); + window.requestAnimationFrame(() => e.target.submit()); + }); + } + }, true); + this.on("submit", (e) => { + let phxEvent = e.target.getAttribute(this.binding("submit")); + if (!phxEvent) { + return; + } + e.preventDefault(); + e.target.disabled = true; + this.withinOwners(e.target, (view) => { + js_default.exec("submit", phxEvent, view, e.target, ["push", {}]); + }); + }, false); + for (let type of ["change", "input"]) { + this.on(type, (e) => { + let phxChange = this.binding("change"); + let input = e.target; + let inputEvent = input.getAttribute(phxChange); + let formEvent = input.form && input.form.getAttribute(phxChange); + let phxEvent = inputEvent || formEvent; + if (!phxEvent) { + return; + } + if (input.type === "number" && input.validity && input.validity.badInput) { + return; + } + let dispatcher = inputEvent ? input : input.form; + let currentIterations = iterations; + iterations++; + let { at, type: lastType } = dom_default.private(input, "prev-iteration") || {}; + if (at === currentIterations - 1 && type !== lastType) { + return; + } + dom_default.putPrivate(input, "prev-iteration", { at: currentIterations, type }); + this.debounce(input, e, type, () => { + this.withinOwners(dispatcher, (view) => { + dom_default.putPrivate(input, PHX_HAS_FOCUSED, true); + if (!dom_default.isTextualInput(input)) { + this.setActiveElement(input); + } + js_default.exec("change", phxEvent, view, input, ["push", { _target: e.target.name, dispatcher }]); + }); + }); + }, false); + } + } + debounce(el, event, eventType, callback) { + if (eventType === "blur" || eventType === "focusout") { + return callback(); + } + let phxDebounce = this.binding(PHX_DEBOUNCE); + let phxThrottle = this.binding(PHX_THROTTLE); + let defaultDebounce = this.defaults.debounce.toString(); + let defaultThrottle = this.defaults.throttle.toString(); + this.withinOwners(el, (view) => { + let asyncFilter = () => !view.isDestroyed() && document.body.contains(el); + dom_default.debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, () => { + callback(); + }); + }); + } + silenceEvents(callback) { + this.silenced = true; + callback(); + this.silenced = false; + } + on(event, callback) { + window.addEventListener(event, (e) => { + if (!this.silenced) { + callback(e); + } + }); + } +}; +var TransitionSet = class { + constructor() { + this.transitions = new Set(); + this.pendingOps = []; + this.reset(); + } + reset() { + this.transitions.forEach((timer) => { + clearTimeout(timer); + this.transitions.delete(timer); + }); + this.flushPendingOps(); + } + after(callback) { + if (this.size() === 0) { + callback(); + } else { + this.pushPendingOp(callback); + } + } + addTransition(time, onStart, onDone) { + onStart(); + let timer = setTimeout(() => { + this.transitions.delete(timer); + onDone(); + if (this.size() === 0) { + this.flushPendingOps(); + } + }, time); + this.transitions.add(timer); + } + pushPendingOp(op) { + this.pendingOps.push(op); + } + size() { + return this.transitions.size; + } + flushPendingOps() { + this.pendingOps.forEach((op) => op()); + this.pendingOps = []; + } +}; +export { + LiveSocket +}; diff --git a/priv/static/common/phx.js b/priv/static/common/phx.js new file mode 100644 index 0000000..45a61f3 --- /dev/null +++ b/priv/static/common/phx.js @@ -0,0 +1,1121 @@ +// js/phoenix/utils.js +var closure = (value) => { + if (typeof value === "function") { + return value; + } else { + let closure2 = function () { + return value; + }; + return closure2; + } +}; + +// js/phoenix/constants.js +var globalSelf = typeof self !== "undefined" ? self : null; +var phxWindow = typeof window !== "undefined" ? window : null; +var global = globalSelf || phxWindow || global; +var DEFAULT_VSN = "2.0.0"; +var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; +var DEFAULT_TIMEOUT = 1e4; +var WS_CLOSE_NORMAL = 1e3; +var CHANNEL_STATES = { + closed: "closed", + errored: "errored", + joined: "joined", + joining: "joining", + leaving: "leaving" +}; +var CHANNEL_EVENTS = { + close: "phx_close", + error: "phx_error", + join: "phx_join", + reply: "phx_reply", + leave: "phx_leave" +}; +var TRANSPORTS = { + longpoll: "longpoll", + websocket: "websocket" +}; +var XHR_STATES = { + complete: 4 +}; + +// js/phoenix/push.js +var Push = class { + constructor(channel, event, payload, timeout) { + this.channel = channel; + this.event = event; + this.payload = payload || function () { + return {}; + }; + this.receivedResp = null; + this.timeout = timeout; + this.timeoutTimer = null; + this.recHooks = []; + this.sent = false; + } + resend(timeout) { + this.timeout = timeout; + this.reset(); + this.send(); + } + send() { + if (this.hasReceived("timeout")) { + return; + } + this.startTimeout(); + this.sent = true; + this.channel.socket.push({ + topic: this.channel.topic, + event: this.event, + payload: this.payload(), + ref: this.ref, + join_ref: this.channel.joinRef() + }); + } + receive(status, callback) { + if (this.hasReceived(status)) { + callback(this.receivedResp.response); + } + this.recHooks.push({ status, callback }); + return this; + } + reset() { + this.cancelRefEvent(); + this.ref = null; + this.refEvent = null; + this.receivedResp = null; + this.sent = false; + } + matchReceive({ status, response, _ref }) { + this.recHooks.filter((h) => h.status === status).forEach((h) => h.callback(response)); + } + cancelRefEvent() { + if (!this.refEvent) { + return; + } + this.channel.off(this.refEvent); + } + cancelTimeout() { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + startTimeout() { + if (this.timeoutTimer) { + this.cancelTimeout(); + } + this.ref = this.channel.socket.makeRef(); + this.refEvent = this.channel.replyEventName(this.ref); + this.channel.on(this.refEvent, (payload) => { + this.cancelRefEvent(); + this.cancelTimeout(); + this.receivedResp = payload; + this.matchReceive(payload); + }); + this.timeoutTimer = setTimeout(() => { + this.trigger("timeout", {}); + }, this.timeout); + } + hasReceived(status) { + return this.receivedResp && this.receivedResp.status === status; + } + trigger(status, response) { + this.channel.trigger(this.refEvent, { status, response }); + } +}; + +// js/phoenix/timer.js +var Timer = class { + constructor(callback, timerCalc) { + this.callback = callback; + this.timerCalc = timerCalc; + this.timer = null; + this.tries = 0; + } + reset() { + this.tries = 0; + clearTimeout(this.timer); + } + scheduleTimeout() { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.tries = this.tries + 1; + this.callback(); + }, this.timerCalc(this.tries + 1)); + } +}; + +// js/phoenix/channel.js +var Channel = class { + constructor(topic, params, socket) { + this.state = CHANNEL_STATES.closed; + this.topic = topic; + this.params = closure(params || {}); + this.socket = socket; + this.bindings = []; + this.bindingRef = 0; + this.timeout = this.socket.timeout; + this.joinedOnce = false; + this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); + this.pushBuffer = []; + this.stateChangeRefs = []; + this.rejoinTimer = new Timer(() => { + if (this.socket.isConnected()) { + this.rejoin(); + } + }, this.socket.rejoinAfterMs); + this.stateChangeRefs.push(this.socket.onError(() => this.rejoinTimer.reset())); + this.stateChangeRefs.push(this.socket.onOpen(() => { + this.rejoinTimer.reset(); + if (this.isErrored()) { + this.rejoin(); + } + })); + this.joinPush.receive("ok", () => { + this.state = CHANNEL_STATES.joined; + this.rejoinTimer.reset(); + this.pushBuffer.forEach((pushEvent) => pushEvent.send()); + this.pushBuffer = []; + }); + this.joinPush.receive("error", () => { + this.state = CHANNEL_STATES.errored; + if (this.socket.isConnected()) { + this.rejoinTimer.scheduleTimeout(); + } + }); + this.onClose(() => { + this.rejoinTimer.reset(); + if (this.socket.hasLogger()) + this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`); + this.state = CHANNEL_STATES.closed; + this.socket.remove(this); + }); + this.onError((reason) => { + if (this.socket.hasLogger()) + this.socket.log("channel", `error ${this.topic}`, reason); + if (this.isJoining()) { + this.joinPush.reset(); + } + this.state = CHANNEL_STATES.errored; + if (this.socket.isConnected()) { + this.rejoinTimer.scheduleTimeout(); + } + }); + this.joinPush.receive("timeout", () => { + if (this.socket.hasLogger()) + this.socket.log("channel", `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout); + let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), this.timeout); + leavePush.send(); + this.state = CHANNEL_STATES.errored; + this.joinPush.reset(); + if (this.socket.isConnected()) { + this.rejoinTimer.scheduleTimeout(); + } + }); + this.on(CHANNEL_EVENTS.reply, (payload, ref) => { + this.trigger(this.replyEventName(ref), payload); + }); + } + join(timeout = this.timeout) { + if (this.joinedOnce) { + throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance"); + } else { + this.timeout = timeout; + this.joinedOnce = true; + this.rejoin(); + return this.joinPush; + } + } + onClose(callback) { + this.on(CHANNEL_EVENTS.close, callback); + } + onError(callback) { + return this.on(CHANNEL_EVENTS.error, (reason) => callback(reason)); + } + on(event, callback) { + let ref = this.bindingRef++; + this.bindings.push({ event, ref, callback }); + return ref; + } + off(event, ref) { + this.bindings = this.bindings.filter((bind) => { + return !(bind.event === event && (typeof ref === "undefined" || ref === bind.ref)); + }); + } + canPush() { + return this.socket.isConnected() && this.isJoined(); + } + push(event, payload, timeout = this.timeout) { + payload = payload || {}; + if (!this.joinedOnce) { + throw new Error(`tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`); + } + let pushEvent = new Push(this, event, function () { + return payload; + }, timeout); + if (this.canPush()) { + pushEvent.send(); + } else { + pushEvent.startTimeout(); + this.pushBuffer.push(pushEvent); + } + return pushEvent; + } + leave(timeout = this.timeout) { + this.rejoinTimer.reset(); + this.joinPush.cancelTimeout(); + this.state = CHANNEL_STATES.leaving; + let onClose = () => { + if (this.socket.hasLogger()) + this.socket.log("channel", `leave ${this.topic}`); + this.trigger(CHANNEL_EVENTS.close, "leave"); + }; + let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), timeout); + leavePush.receive("ok", () => onClose()).receive("timeout", () => onClose()); + leavePush.send(); + if (!this.canPush()) { + leavePush.trigger("ok", {}); + } + return leavePush; + } + onMessage(_event, payload, _ref) { + return payload; + } + isMember(topic, event, payload, joinRef) { + if (this.topic !== topic) { + return false; + } + if (joinRef && joinRef !== this.joinRef()) { + if (this.socket.hasLogger()) + this.socket.log("channel", "dropping outdated message", { topic, event, payload, joinRef }); + return false; + } else { + return true; + } + } + joinRef() { + return this.joinPush.ref; + } + rejoin(timeout = this.timeout) { + if (this.isLeaving()) { + return; + } + this.socket.leaveOpenTopic(this.topic); + this.state = CHANNEL_STATES.joining; + this.joinPush.resend(timeout); + } + trigger(event, payload, ref, joinRef) { + let handledPayload = this.onMessage(event, payload, ref, joinRef); + if (payload && !handledPayload) { + throw new Error("channel onMessage callbacks must return the payload, modified or unmodified"); + } + let eventBindings = this.bindings.filter((bind) => bind.event === event); + for (let i = 0; i < eventBindings.length; i++) { + let bind = eventBindings[i]; + bind.callback(handledPayload, ref, joinRef || this.joinRef()); + } + } + replyEventName(ref) { + return `chan_reply_${ref}`; + } + isClosed() { + return this.state === CHANNEL_STATES.closed; + } + isErrored() { + return this.state === CHANNEL_STATES.errored; + } + isJoined() { + return this.state === CHANNEL_STATES.joined; + } + isJoining() { + return this.state === CHANNEL_STATES.joining; + } + isLeaving() { + return this.state === CHANNEL_STATES.leaving; + } +}; + +// js/phoenix/ajax.js +var Ajax = class { + static request(method, endPoint, accept, body, timeout, ontimeout, callback) { + if (global.XDomainRequest) { + let req = new global.XDomainRequest(); + return this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); + } else { + let req = new global.XMLHttpRequest(); + return this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback); + } + } + static xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { + req.timeout = timeout; + req.open(method, endPoint); + req.onload = () => { + let response = this.parseJSON(req.responseText); + callback && callback(response); + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + req.onprogress = () => { + }; + req.send(body); + return req; + } + static xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { + req.open(method, endPoint, true); + req.timeout = timeout; + req.setRequestHeader("Content-Type", accept); + req.onerror = () => callback && callback(null); + req.onreadystatechange = () => { + if (req.readyState === XHR_STATES.complete && callback) { + let response = this.parseJSON(req.responseText); + callback(response); + } + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + req.send(body); + return req; + } + static parseJSON(resp) { + if (!resp || resp === "") { + return null; + } + try { + return JSON.parse(resp); + } catch (e) { + console && console.log("failed to parse JSON response", resp); + return null; + } + } + static serialize(obj, parentKey) { + let queryStr = []; + for (var key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) { + continue; + } + let paramKey = parentKey ? `${parentKey}[${key}]` : key; + let paramVal = obj[key]; + if (typeof paramVal === "object") { + queryStr.push(this.serialize(paramVal, paramKey)); + } else { + queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)); + } + } + return queryStr.join("&"); + } + static appendParams(url, params) { + if (Object.keys(params).length === 0) { + return url; + } + let prefix = url.match(/\?/) ? "&" : "?"; + return `${url}${prefix}${this.serialize(params)}`; + } +}; + +// js/phoenix/longpoll.js +var LongPoll = class { + constructor(endPoint) { + this.endPoint = null; + this.token = null; + this.skipHeartbeat = true; + this.reqs = /* @__PURE__ */ new Set(); + this.onopen = function () { + }; + this.onerror = function () { + }; + this.onmessage = function () { + }; + this.onclose = function () { + }; + this.pollEndpoint = this.normalizeEndpoint(endPoint); + this.readyState = SOCKET_STATES.connecting; + this.poll(); + } + normalizeEndpoint(endPoint) { + return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll); + } + endpointURL() { + return Ajax.appendParams(this.pollEndpoint, { token: this.token }); + } + closeAndRetry(code, reason, wasClean) { + this.close(code, reason, wasClean); + this.readyState = SOCKET_STATES.connecting; + } + ontimeout() { + this.onerror("timeout"); + this.closeAndRetry(1005, "timeout", false); + } + isActive() { + return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting; + } + poll() { + this.ajax("GET", null, () => this.ontimeout(), (resp) => { + if (resp) { + var { status, token, messages } = resp; + this.token = token; + } else { + status = 0; + } + switch (status) { + case 200: + messages.forEach((msg) => { + setTimeout(() => this.onmessage({ data: msg }), 0); + }); + this.poll(); + break; + case 204: + this.poll(); + break; + case 410: + this.readyState = SOCKET_STATES.open; + this.onopen({}); + this.poll(); + break; + case 403: + this.onerror(403); + this.close(1008, "forbidden", false); + break; + case 0: + case 500: + this.onerror(500); + this.closeAndRetry(1011, "internal server error", 500); + break; + default: + throw new Error(`unhandled poll status ${status}`); + } + }); + } + send(body) { + this.ajax("POST", body, () => this.onerror("timeout"), (resp) => { + if (!resp || resp.status !== 200) { + this.onerror(resp && resp.status); + this.closeAndRetry(1011, "internal server error", false); + } + }); + } + close(code, reason, wasClean) { + for (let req of this.reqs) { + req.abort(); + } + this.readyState = SOCKET_STATES.closed; + let opts = Object.assign({ code: 1e3, reason: void 0, wasClean: true }, { code, reason, wasClean }); + if (typeof CloseEvent !== "undefined") { + this.onclose(new CloseEvent("close", opts)); + } else { + this.onclose(opts); + } + } + ajax(method, body, onCallerTimeout, callback) { + let req; + let ontimeout = () => { + this.reqs.delete(req); + onCallerTimeout(); + }; + req = Ajax.request(method, this.endpointURL(), "application/json", body, this.timeout, ontimeout, (resp) => { + this.reqs.delete(req); + if (this.isActive()) { + callback(resp); + } + }); + this.reqs.add(req); + } +}; + +// js/phoenix/presence.js +var Presence = class { + constructor(channel, opts = {}) { + let events = opts.events || { state: "presence_state", diff: "presence_diff" }; + this.state = {}; + this.pendingDiffs = []; + this.channel = channel; + this.joinRef = null; + this.caller = { + onJoin: function () { + }, + onLeave: function () { + }, + onSync: function () { + } + }; + this.channel.on(events.state, (newState) => { + let { onJoin, onLeave, onSync } = this.caller; + this.joinRef = this.channel.joinRef(); + this.state = Presence.syncState(this.state, newState, onJoin, onLeave); + this.pendingDiffs.forEach((diff) => { + this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave); + }); + this.pendingDiffs = []; + onSync(); + }); + this.channel.on(events.diff, (diff) => { + let { onJoin, onLeave, onSync } = this.caller; + if (this.inPendingSyncState()) { + this.pendingDiffs.push(diff); + } else { + this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave); + onSync(); + } + }); + } + onJoin(callback) { + this.caller.onJoin = callback; + } + onLeave(callback) { + this.caller.onLeave = callback; + } + onSync(callback) { + this.caller.onSync = callback; + } + list(by) { + return Presence.list(this.state, by); + } + inPendingSyncState() { + return !this.joinRef || this.joinRef !== this.channel.joinRef(); + } + static syncState(currentState, newState, onJoin, onLeave) { + let state = this.clone(currentState); + let joins = {}; + let leaves = {}; + this.map(state, (key, presence) => { + if (!newState[key]) { + leaves[key] = presence; + } + }); + this.map(newState, (key, newPresence) => { + let currentPresence = state[key]; + if (currentPresence) { + let newRefs = newPresence.metas.map((m) => m.phx_ref); + let curRefs = currentPresence.metas.map((m) => m.phx_ref); + let joinedMetas = newPresence.metas.filter((m) => curRefs.indexOf(m.phx_ref) < 0); + let leftMetas = currentPresence.metas.filter((m) => newRefs.indexOf(m.phx_ref) < 0); + if (joinedMetas.length > 0) { + joins[key] = newPresence; + joins[key].metas = joinedMetas; + } + if (leftMetas.length > 0) { + leaves[key] = this.clone(currentPresence); + leaves[key].metas = leftMetas; + } + } else { + joins[key] = newPresence; + } + }); + return this.syncDiff(state, { joins, leaves }, onJoin, onLeave); + } + static syncDiff(state, diff, onJoin, onLeave) { + let { joins, leaves } = this.clone(diff); + if (!onJoin) { + onJoin = function () { + }; + } + if (!onLeave) { + onLeave = function () { + }; + } + this.map(joins, (key, newPresence) => { + let currentPresence = state[key]; + state[key] = this.clone(newPresence); + if (currentPresence) { + let joinedRefs = state[key].metas.map((m) => m.phx_ref); + let curMetas = currentPresence.metas.filter((m) => joinedRefs.indexOf(m.phx_ref) < 0); + state[key].metas.unshift(...curMetas); + } + onJoin(key, currentPresence, newPresence); + }); + this.map(leaves, (key, leftPresence) => { + let currentPresence = state[key]; + if (!currentPresence) { + return; + } + let refsToRemove = leftPresence.metas.map((m) => m.phx_ref); + currentPresence.metas = currentPresence.metas.filter((p) => { + return refsToRemove.indexOf(p.phx_ref) < 0; + }); + onLeave(key, currentPresence, leftPresence); + if (currentPresence.metas.length === 0) { + delete state[key]; + } + }); + return state; + } + static list(presences, chooser) { + if (!chooser) { + chooser = function (key, pres) { + return pres; + }; + } + return this.map(presences, (key, presence) => { + return chooser(key, presence); + }); + } + static map(obj, func) { + return Object.getOwnPropertyNames(obj).map((key) => func(key, obj[key])); + } + static clone(obj) { + return JSON.parse(JSON.stringify(obj)); + } +}; + +// js/phoenix/serializer.js +var serializer_default = { + HEADER_LENGTH: 1, + META_LENGTH: 4, + KINDS: { push: 0, reply: 1, broadcast: 2 }, + encode(msg, callback) { + if (msg.payload.constructor === ArrayBuffer) { + return callback(this.binaryEncode(msg)); + } else { + let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]; + return callback(JSON.stringify(payload)); + } + }, + decode(rawPayload, callback) { + if (rawPayload.constructor === ArrayBuffer) { + return callback(this.binaryDecode(rawPayload)); + } else { + let [join_ref, ref, topic, event, payload] = JSON.parse(rawPayload); + return callback({ join_ref, ref, topic, event, payload }); + } + }, + binaryEncode(message) { + let { join_ref, ref, event, topic, payload } = message; + let metaLength = this.META_LENGTH + join_ref.length + ref.length + topic.length + event.length; + let header = new ArrayBuffer(this.HEADER_LENGTH + metaLength); + let view = new DataView(header); + let offset = 0; + view.setUint8(offset++, this.KINDS.push); + view.setUint8(offset++, join_ref.length); + view.setUint8(offset++, ref.length); + view.setUint8(offset++, topic.length); + view.setUint8(offset++, event.length); + Array.from(join_ref, (char) => view.setUint8(offset++, char.charCodeAt(0))); + Array.from(ref, (char) => view.setUint8(offset++, char.charCodeAt(0))); + Array.from(topic, (char) => view.setUint8(offset++, char.charCodeAt(0))); + Array.from(event, (char) => view.setUint8(offset++, char.charCodeAt(0))); + var combined = new Uint8Array(header.byteLength + payload.byteLength); + combined.set(new Uint8Array(header), 0); + combined.set(new Uint8Array(payload), header.byteLength); + return combined.buffer; + }, + binaryDecode(buffer) { + let view = new DataView(buffer); + let kind = view.getUint8(0); + let decoder = new TextDecoder(); + switch (kind) { + case this.KINDS.push: + return this.decodePush(buffer, view, decoder); + case this.KINDS.reply: + return this.decodeReply(buffer, view, decoder); + case this.KINDS.broadcast: + return this.decodeBroadcast(buffer, view, decoder); + } + }, + decodePush(buffer, view, decoder) { + let joinRefSize = view.getUint8(1); + let topicSize = view.getUint8(2); + let eventSize = view.getUint8(3); + let offset = this.HEADER_LENGTH + this.META_LENGTH - 1; + let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)); + offset = offset + joinRefSize; + let topic = decoder.decode(buffer.slice(offset, offset + topicSize)); + offset = offset + topicSize; + let event = decoder.decode(buffer.slice(offset, offset + eventSize)); + offset = offset + eventSize; + let data = buffer.slice(offset, buffer.byteLength); + return { join_ref: joinRef, ref: null, topic, event, payload: data }; + }, + decodeReply(buffer, view, decoder) { + let joinRefSize = view.getUint8(1); + let refSize = view.getUint8(2); + let topicSize = view.getUint8(3); + let eventSize = view.getUint8(4); + let offset = this.HEADER_LENGTH + this.META_LENGTH; + let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)); + offset = offset + joinRefSize; + let ref = decoder.decode(buffer.slice(offset, offset + refSize)); + offset = offset + refSize; + let topic = decoder.decode(buffer.slice(offset, offset + topicSize)); + offset = offset + topicSize; + let event = decoder.decode(buffer.slice(offset, offset + eventSize)); + offset = offset + eventSize; + let data = buffer.slice(offset, buffer.byteLength); + let payload = { status: event, response: data }; + return { join_ref: joinRef, ref, topic, event: CHANNEL_EVENTS.reply, payload }; + }, + decodeBroadcast(buffer, view, decoder) { + let topicSize = view.getUint8(1); + let eventSize = view.getUint8(2); + let offset = this.HEADER_LENGTH + 2; + let topic = decoder.decode(buffer.slice(offset, offset + topicSize)); + offset = offset + topicSize; + let event = decoder.decode(buffer.slice(offset, offset + eventSize)); + offset = offset + eventSize; + let data = buffer.slice(offset, buffer.byteLength); + return { join_ref: null, ref: null, topic, event, payload: data }; + } +}; + +// js/phoenix/socket.js +var Socket = class { + constructor(endPoint, opts = {}) { + this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; + this.channels = []; + this.sendBuffer = []; + this.ref = 0; + this.timeout = opts.timeout || DEFAULT_TIMEOUT; + this.transport = opts.transport || global.WebSocket || LongPoll; + this.establishedConnections = 0; + this.defaultEncoder = serializer_default.encode.bind(serializer_default); + this.defaultDecoder = serializer_default.decode.bind(serializer_default); + this.closeWasClean = false; + this.binaryType = opts.binaryType || "arraybuffer"; + this.connectClock = 1; + if (this.transport !== LongPoll) { + this.encode = opts.encode || this.defaultEncoder; + this.decode = opts.decode || this.defaultDecoder; + } else { + this.encode = this.defaultEncoder; + this.decode = this.defaultDecoder; + } + let awaitingConnectionOnPageShow = null; + if (phxWindow && phxWindow.addEventListener) { + phxWindow.addEventListener("pagehide", (_e) => { + if (this.conn) { + this.disconnect(); + awaitingConnectionOnPageShow = this.connectClock; + } + }); + phxWindow.addEventListener("pageshow", (_e) => { + if (awaitingConnectionOnPageShow === this.connectClock) { + awaitingConnectionOnPageShow = null; + this.connect(); + } + }); + } + this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 3e4; + this.rejoinAfterMs = (tries) => { + if (opts.rejoinAfterMs) { + return opts.rejoinAfterMs(tries); + } else { + return [1e3, 2e3, 5e3][tries - 1] || 1e4; + } + }; + this.reconnectAfterMs = (tries) => { + if (opts.reconnectAfterMs) { + return opts.reconnectAfterMs(tries); + } else { + return [10, 50, 100, 150, 200, 250, 500, 1e3, 2e3][tries - 1] || 5e3; + } + }; + this.logger = opts.logger || null; + this.longpollerTimeout = opts.longpollerTimeout || 2e4; + this.params = closure(opts.params || {}); + this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`; + this.vsn = opts.vsn || DEFAULT_VSN; + this.heartbeatTimer = null; + this.pendingHeartbeatRef = null; + this.reconnectTimer = new Timer(() => { + this.teardown(() => this.connect()); + }, this.reconnectAfterMs); + } + getLongPollTransport() { + return LongPoll; + } + replaceTransport(newTransport) { + this.connectClock++; + this.closeWasClean = true; + this.reconnectTimer.reset(); + this.sendBuffer = []; + if (this.conn) { + this.conn.close(); + this.conn = null; + } + this.transport = newTransport; + } + protocol() { + return location.protocol.match(/^https/) ? "wss" : "ws"; + } + endPointURL() { + let uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params()), { vsn: this.vsn }); + if (uri.charAt(0) !== "/") { + return uri; + } + if (uri.charAt(1) === "/") { + return `${this.protocol()}:${uri}`; + } + return `${this.protocol()}://${location.host}${uri}`; + } + disconnect(callback, code, reason) { + this.connectClock++; + this.closeWasClean = true; + this.reconnectTimer.reset(); + this.teardown(callback, code, reason); + } + connect(params) { + if (params) { + console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"); + this.params = closure(params); + } + if (this.conn) { + return; + } + this.connectClock++; + this.closeWasClean = false; + this.conn = new this.transport(this.endPointURL()); + this.conn.binaryType = this.binaryType; + this.conn.timeout = this.longpollerTimeout; + this.conn.onopen = () => this.onConnOpen(); + this.conn.onerror = (error) => this.onConnError(error); + this.conn.onmessage = (event) => this.onConnMessage(event); + this.conn.onclose = (event) => this.onConnClose(event); + } + log(kind, msg, data) { + this.logger(kind, msg, data); + } + hasLogger() { + return this.logger !== null; + } + onOpen(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.open.push([ref, callback]); + return ref; + } + onClose(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.close.push([ref, callback]); + return ref; + } + onError(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.error.push([ref, callback]); + return ref; + } + onMessage(callback) { + let ref = this.makeRef(); + this.stateChangeCallbacks.message.push([ref, callback]); + return ref; + } + ping(callback) { + if (!this.isConnected()) { + return false; + } + let ref = this.makeRef(); + let startTime = Date.now(); + this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref }); + let onMsgRef = this.onMessage((msg) => { + if (msg.ref === ref) { + this.off([onMsgRef]); + callback(Date.now() - startTime); + } + }); + return true; + } + onConnOpen() { + if (this.hasLogger()) + this.log("transport", `connected to ${this.endPointURL()}`); + this.closeWasClean = false; + this.establishedConnections++; + this.flushSendBuffer(); + this.reconnectTimer.reset(); + this.resetHeartbeat(); + this.stateChangeCallbacks.open.forEach(([, callback]) => callback()); + } + heartbeatTimeout() { + if (this.pendingHeartbeatRef) { + this.pendingHeartbeatRef = null; + if (this.hasLogger()) { + this.log("transport", "heartbeat timeout. Attempting to re-establish connection"); + } + this.abnormalClose("heartbeat timeout"); + } + } + resetHeartbeat() { + if (this.conn && this.conn.skipHeartbeat) { + return; + } + this.pendingHeartbeatRef = null; + clearTimeout(this.heartbeatTimer); + setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs); + } + teardown(callback, code, reason) { + if (!this.conn) { + return callback && callback(); + } + this.waitForBufferDone(() => { + if (this.conn) { + if (code) { + this.conn.close(code, reason || ""); + } else { + this.conn.close(); + } + } + this.waitForSocketClosed(() => { + if (this.conn) { + this.conn.onclose = function () { + }; + this.conn = null; + } + callback && callback(); + }); + }); + } + waitForBufferDone(callback, tries = 1) { + if (tries === 5 || !this.conn || !this.conn.bufferedAmount) { + callback(); + return; + } + setTimeout(() => { + this.waitForBufferDone(callback, tries + 1); + }, 150 * tries); + } + waitForSocketClosed(callback, tries = 1) { + if (tries === 5 || !this.conn || this.conn.readyState === SOCKET_STATES.closed) { + callback(); + return; + } + setTimeout(() => { + this.waitForSocketClosed(callback, tries + 1); + }, 150 * tries); + } + onConnClose(event) { + let closeCode = event && event.code; + if (this.hasLogger()) + this.log("transport", "close", event); + this.triggerChanError(); + clearTimeout(this.heartbeatTimer); + if (!this.closeWasClean && closeCode !== 1e3) { + this.reconnectTimer.scheduleTimeout(); + } + this.stateChangeCallbacks.close.forEach(([, callback]) => callback(event)); + } + onConnError(error) { + if (this.hasLogger()) + this.log("transport", error); + let transportBefore = this.transport; + let establishedBefore = this.establishedConnections; + this.stateChangeCallbacks.error.forEach(([, callback]) => { + callback(error, transportBefore, establishedBefore); + }); + if (transportBefore === this.transport || establishedBefore > 0) { + this.triggerChanError(); + } + } + triggerChanError() { + this.channels.forEach((channel) => { + if (!(channel.isErrored() || channel.isLeaving() || channel.isClosed())) { + channel.trigger(CHANNEL_EVENTS.error); + } + }); + } + connectionState() { + switch (this.conn && this.conn.readyState) { + case SOCKET_STATES.connecting: + return "connecting"; + case SOCKET_STATES.open: + return "open"; + case SOCKET_STATES.closing: + return "closing"; + default: + return "closed"; + } + } + isConnected() { + return this.connectionState() === "open"; + } + remove(channel) { + this.off(channel.stateChangeRefs); + this.channels = this.channels.filter((c) => c.joinRef() !== channel.joinRef()); + } + off(refs) { + for (let key in this.stateChangeCallbacks) { + this.stateChangeCallbacks[key] = this.stateChangeCallbacks[key].filter(([ref]) => { + return refs.indexOf(ref) === -1; + }); + } + } + channel(topic, chanParams = {}) { + let chan = new Channel(topic, chanParams, this); + this.channels.push(chan); + return chan; + } + push(data) { + if (this.hasLogger()) { + let { topic, event, payload, ref, join_ref } = data; + this.log("push", `${topic} ${event} (${join_ref}, ${ref})`, payload); + } + if (this.isConnected()) { + this.encode(data, (result) => this.conn.send(result)); + } else { + this.sendBuffer.push(() => this.encode(data, (result) => this.conn.send(result))); + } + } + makeRef() { + let newRef = this.ref + 1; + if (newRef === this.ref) { + this.ref = 0; + } else { + this.ref = newRef; + } + return this.ref.toString(); + } + sendHeartbeat() { + if (this.pendingHeartbeatRef && !this.isConnected()) { + return; + } + this.pendingHeartbeatRef = this.makeRef(); + this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef }); + this.heartbeatTimer = setTimeout(() => this.heartbeatTimeout(), this.heartbeatIntervalMs); + } + abnormalClose(reason) { + this.closeWasClean = false; + if (this.isConnected()) { + this.conn.close(WS_CLOSE_NORMAL, reason); + } + } + flushSendBuffer() { + if (this.isConnected() && this.sendBuffer.length > 0) { + this.sendBuffer.forEach((callback) => callback()); + this.sendBuffer = []; + } + } + onConnMessage(rawMessage) { + this.decode(rawMessage.data, (msg) => { + let { topic, event, payload, ref, join_ref } = msg; + if (ref && ref === this.pendingHeartbeatRef) { + clearTimeout(this.heartbeatTimer); + this.pendingHeartbeatRef = null; + setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs); + } + if (this.hasLogger()) + this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`, payload); + for (let i = 0; i < this.channels.length; i++) { + const channel = this.channels[i]; + if (!channel.isMember(topic, event, payload, join_ref)) { + continue; + } + channel.trigger(event, payload, ref, join_ref); + } + for (let i = 0; i < this.stateChangeCallbacks.message.length; i++) { + let [, callback] = this.stateChangeCallbacks.message[i]; + callback(msg); + } + }); + } + leaveOpenTopic(topic) { + let dupChannel = this.channels.find((c) => c.topic === topic && (c.isJoined() || c.isJoining())); + if (dupChannel) { + if (this.hasLogger()) + this.log("transport", `leaving duplicate topic "${topic}"`); + dupChannel.leave(); + } + } +}; +export { + Channel, + LongPoll, + Presence, + serializer_default as Serializer, + Socket +}; diff --git a/priv/static/common/vendor/milligram.css b/priv/static/common/vendor/milligram.css new file mode 100644 index 0000000..87c79bd --- /dev/null +++ b/priv/static/common/vendor/milligram.css @@ -0,0 +1,645 @@ +/*! + * Milligram v1.4.1 + * https://milligram.io + * + * Copyright (c) 2020 CJ Patoilo + * Licensed under the MIT license + */ + +*, +*:after, +*:before { + box-sizing: inherit; +} + +html { + box-sizing: border-box; + font-size: 62.5%; +} + +body { + color: #606c76; + font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; + font-size: 1.6em; + font-weight: 300; + letter-spacing: .01em; + line-height: 1.6; +} + +blockquote { + border-left: 0.3rem solid #d1d1d1; + margin-left: 0; + margin-right: 0; + padding: 1rem 1.5rem; +} + +blockquote *:last-child { + margin-bottom: 0; +} + +.button, +button, +input[type='button'], +input[type='reset'], +input[type='submit'] { + background-color: #9b4dca; + border: 0.1rem solid #9b4dca; + border-radius: .4rem; + color: #fff; + cursor: pointer; + display: inline-block; + font-size: 1.1rem; + font-weight: 700; + height: 3.8rem; + letter-spacing: .1rem; + line-height: 3.8rem; + padding: 0 3.0rem; + text-align: center; + text-decoration: none; + text-transform: uppercase; + white-space: nowrap; +} + +.button:focus, +.button:hover, +button:focus, +button:hover, +input[type='button']:focus, +input[type='button']:hover, +input[type='reset']:focus, +input[type='reset']:hover, +input[type='submit']:focus, +input[type='submit']:hover { + background-color: #606c76; + border-color: #606c76; + color: #fff; + outline: 0; +} + +.button[disabled], +button[disabled], +input[type='button'][disabled], +input[type='reset'][disabled], +input[type='submit'][disabled] { + cursor: default; + opacity: .5; +} + +.button[disabled]:focus, +.button[disabled]:hover, +button[disabled]:focus, +button[disabled]:hover, +input[type='button'][disabled]:focus, +input[type='button'][disabled]:hover, +input[type='reset'][disabled]:focus, +input[type='reset'][disabled]:hover, +input[type='submit'][disabled]:focus, +input[type='submit'][disabled]:hover { + background-color: #9b4dca; + border-color: #9b4dca; +} + +.button.button-outline, +button.button-outline, +input[type='button'].button-outline, +input[type='reset'].button-outline, +input[type='submit'].button-outline { + background-color: transparent; + color: #9b4dca; +} + +.button.button-outline:focus, +.button.button-outline:hover, +button.button-outline:focus, +button.button-outline:hover, +input[type='button'].button-outline:focus, +input[type='button'].button-outline:hover, +input[type='reset'].button-outline:focus, +input[type='reset'].button-outline:hover, +input[type='submit'].button-outline:focus, +input[type='submit'].button-outline:hover { + background-color: transparent; + border-color: #606c76; + color: #606c76; +} + +.button.button-outline[disabled]:focus, +.button.button-outline[disabled]:hover, +button.button-outline[disabled]:focus, +button.button-outline[disabled]:hover, +input[type='button'].button-outline[disabled]:focus, +input[type='button'].button-outline[disabled]:hover, +input[type='reset'].button-outline[disabled]:focus, +input[type='reset'].button-outline[disabled]:hover, +input[type='submit'].button-outline[disabled]:focus, +input[type='submit'].button-outline[disabled]:hover { + border-color: inherit; + color: #9b4dca; +} + +.button.button-clear, +button.button-clear, +input[type='button'].button-clear, +input[type='reset'].button-clear, +input[type='submit'].button-clear { + background-color: transparent; + border-color: transparent; + color: #9b4dca; +} + +.button.button-clear:focus, +.button.button-clear:hover, +button.button-clear:focus, +button.button-clear:hover, +input[type='button'].button-clear:focus, +input[type='button'].button-clear:hover, +input[type='reset'].button-clear:focus, +input[type='reset'].button-clear:hover, +input[type='submit'].button-clear:focus, +input[type='submit'].button-clear:hover { + background-color: transparent; + border-color: transparent; + color: #606c76; +} + +.button.button-clear[disabled]:focus, +.button.button-clear[disabled]:hover, +button.button-clear[disabled]:focus, +button.button-clear[disabled]:hover, +input[type='button'].button-clear[disabled]:focus, +input[type='button'].button-clear[disabled]:hover, +input[type='reset'].button-clear[disabled]:focus, +input[type='reset'].button-clear[disabled]:hover, +input[type='submit'].button-clear[disabled]:focus, +input[type='submit'].button-clear[disabled]:hover { + color: #9b4dca; +} + +code { + background: #f4f5f6; + border-radius: .4rem; + font-size: 86%; + margin: 0 .2rem; + padding: .2rem .5rem; + white-space: nowrap; +} + +pre { + background: #f4f5f6; + border-left: 0.3rem solid #9b4dca; + overflow-y: hidden; +} + +pre>code { + border-radius: 0; + display: block; + padding: 1rem 1.5rem; + white-space: pre; +} + +hr { + border: 0; + border-top: 0.1rem solid #f4f5f6; + margin: 3.0rem 0; +} + +input[type='color'], +input[type='date'], +input[type='datetime'], +input[type='datetime-local'], +input[type='email'], +input[type='month'], +input[type='number'], +input[type='password'], +input[type='search'], +input[type='tel'], +input[type='text'], +input[type='url'], +input[type='week'], +input:not([type]), +textarea, +select { + -webkit-appearance: none; + background-color: transparent; + border: 0.1rem solid #d1d1d1; + border-radius: .4rem; + box-shadow: none; + box-sizing: inherit; + height: 3.8rem; + padding: .6rem 1.0rem .7rem; + width: 100%; +} + +input[type='color']:focus, +input[type='date']:focus, +input[type='datetime']:focus, +input[type='datetime-local']:focus, +input[type='email']:focus, +input[type='month']:focus, +input[type='number']:focus, +input[type='password']:focus, +input[type='search']:focus, +input[type='tel']:focus, +input[type='text']:focus, +input[type='url']:focus, +input[type='week']:focus, +input:not([type]):focus, +textarea:focus, +select:focus { + border-color: #9b4dca; + outline: 0; +} + +select { + background: url('data:image/svg+xml;utf8,') center right no-repeat; + padding-right: 3.0rem; +} + +select:focus { + background-image: url('data:image/svg+xml;utf8,'); +} + +select[multiple] { + background: none; + height: auto; +} + +textarea { + min-height: 6.5rem; +} + +label, +legend { + display: block; + font-size: 1.6rem; + font-weight: 700; + margin-bottom: .5rem; +} + +fieldset { + border-width: 0; + padding: 0; +} + +input[type='checkbox'], +input[type='radio'] { + display: inline; +} + +.label-inline { + display: inline-block; + font-weight: normal; + margin-left: .5rem; +} + +.container { + margin: 0 auto; + max-width: 112.0rem; + padding: 0 2.0rem; + position: relative; + width: 100%; +} + +.row { + display: flex; + flex-direction: column; + padding: 0; + width: 100%; +} + +.row.row-no-padding { + padding: 0; +} + +.row.row-no-padding>.column { + padding: 0; +} + +.row.row-wrap { + flex-wrap: wrap; +} + +.row.row-top { + align-items: flex-start; +} + +.row.row-bottom { + align-items: flex-end; +} + +.row.row-center { + align-items: center; +} + +.row.row-stretch { + align-items: stretch; +} + +.row.row-baseline { + align-items: baseline; +} + +.row .column { + display: block; + flex: 1 1 auto; + margin-left: 0; + max-width: 100%; + width: 100%; +} + +.row .column.column-offset-10 { + margin-left: 10%; +} + +.row .column.column-offset-20 { + margin-left: 20%; +} + +.row .column.column-offset-25 { + margin-left: 25%; +} + +.row .column.column-offset-33, +.row .column.column-offset-34 { + margin-left: 33.3333%; +} + +.row .column.column-offset-40 { + margin-left: 40%; +} + +.row .column.column-offset-50 { + margin-left: 50%; +} + +.row .column.column-offset-60 { + margin-left: 60%; +} + +.row .column.column-offset-66, +.row .column.column-offset-67 { + margin-left: 66.6666%; +} + +.row .column.column-offset-75 { + margin-left: 75%; +} + +.row .column.column-offset-80 { + margin-left: 80%; +} + +.row .column.column-offset-90 { + margin-left: 90%; +} + +.row .column.column-10 { + flex: 0 0 10%; + max-width: 10%; +} + +.row .column.column-20 { + flex: 0 0 20%; + max-width: 20%; +} + +.row .column.column-25 { + flex: 0 0 25%; + max-width: 25%; +} + +.row .column.column-33, +.row .column.column-34 { + flex: 0 0 33.3333%; + max-width: 33.3333%; +} + +.row .column.column-40 { + flex: 0 0 40%; + max-width: 40%; +} + +.row .column.column-50 { + flex: 0 0 50%; + max-width: 50%; +} + +.row .column.column-60 { + flex: 0 0 60%; + max-width: 60%; +} + +.row .column.column-66, +.row .column.column-67 { + flex: 0 0 66.6666%; + max-width: 66.6666%; +} + +.row .column.column-75 { + flex: 0 0 75%; + max-width: 75%; +} + +.row .column.column-80 { + flex: 0 0 80%; + max-width: 80%; +} + +.row .column.column-90 { + flex: 0 0 90%; + max-width: 90%; +} + +.row .column .column-top { + align-self: flex-start; +} + +.row .column .column-bottom { + align-self: flex-end; +} + +.row .column .column-center { + align-self: center; +} + +@media (min-width: 40rem) { + .row { + flex-direction: row; + margin-left: -1.0rem; + width: calc(100% + 2.0rem); + } + + .row .column { + margin-bottom: inherit; + padding: 0 1.0rem; + } +} + +a { + color: #9b4dca; + text-decoration: none; +} + +a:focus, +a:hover { + color: #606c76; +} + +dl, +ol, +ul { + list-style: none; + margin-top: 0; + padding-left: 0; +} + +dl dl, +dl ol, +dl ul, +ol dl, +ol ol, +ol ul, +ul dl, +ul ol, +ul ul { + font-size: 90%; + margin: 1.5rem 0 1.5rem 3.0rem; +} + +ol { + list-style: decimal inside; +} + +ul { + list-style: circle inside; +} + +.button, +button, +dd, +dt, +li { + margin-bottom: 1.0rem; +} + +fieldset, +input, +select, +textarea { + margin-bottom: 1.5rem; +} + +blockquote, +dl, +figure, +form, +ol, +p, +pre, +table, +ul { + margin-bottom: 2.5rem; +} + +table { + border-spacing: 0; + display: block; + overflow-x: auto; + text-align: left; + width: 100%; +} + +td, +th { + border-bottom: 0.1rem solid #e1e1e1; + padding: 1.2rem 1.5rem; +} + +td:first-child, +th:first-child { + padding-left: 0; +} + +td:last-child, +th:last-child { + padding-right: 0; +} + +@media (min-width: 40rem) { + table { + display: table; + overflow-x: initial; + } +} + +b, +strong { + font-weight: bold; +} + +p { + margin-top: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-weight: 300; + letter-spacing: -.1rem; + margin-bottom: 2.0rem; + margin-top: 0; +} + +h1 { + font-size: 4.6rem; + line-height: 1.2; +} + +h2 { + font-size: 3.6rem; + line-height: 1.25; +} + +h3 { + font-size: 2.8rem; + line-height: 1.3; +} + +h4 { + font-size: 2.2rem; + letter-spacing: -.08rem; + line-height: 1.35; +} + +h5 { + font-size: 1.8rem; + letter-spacing: -.05rem; + line-height: 1.5; +} + +h6 { + font-size: 1.6rem; + letter-spacing: 0; + line-height: 1.4; +} + +img { + max-width: 100%; +} + +.clearfix:after { + clear: both; + content: ' '; + display: table; +} + +.float-left { + float: left; +} + +.float-right { + float: right; +} diff --git a/priv/static/common/vendor/normalize.css b/priv/static/common/vendor/normalize.css new file mode 100644 index 0000000..fab103a --- /dev/null +++ b/priv/static/common/vendor/normalize.css @@ -0,0 +1,379 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; + /* 1 */ + height: 0; + /* 1 */ + overflow: visible; + /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; + /* 1 */ + text-decoration: underline; + /* 2 */ + text-decoration: underline dotted; + /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + line-height: 1.15; + /* 1 */ + margin: 0; + /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; + /* 1 */ + color: inherit; + /* 2 */ + display: table; + /* 1 */ + max-width: 100%; + /* 1 */ + padding: 0; + /* 3 */ + white-space: normal; + /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; + /* 1 */ + padding: 0; + /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/priv/static/common/vendor/topbar.js b/priv/static/common/vendor/topbar.js new file mode 100644 index 0000000..37f2d92 --- /dev/null +++ b/priv/static/common/vendor/topbar.js @@ -0,0 +1,157 @@ +/** + * @license MIT + * topbar 1.0.0, 2021-01-06 + * http://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + progressTimerId, + fadeTimerId, + currentProgress, + showing, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function () { + if (showing) return; + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(window, window, document));