From 5c8e1178001d30902cbf2109c1f51f2dba7d52df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Mon, 3 May 2021 20:03:19 +0200 Subject: [PATCH] Setup user profiles (#253) * Add initial user config modal * Assign user ids * Update session data to hold user ids * Get users list for specific ids * Render user avatar * User update * Refactor user changes * Subscribe to individual user updates * Show users in side panel * Add sidebar to homepage * Don't generate the same color twice in a row * Add documentation notes * Fix tests * Add tests * Keep users in session data * Rename color to hex_color --- assets/css/components.css | 4 + assets/css/js_interop.css | 23 +++- assets/js/app.js | 11 +- assets/js/lib/user.js | 43 +++++++ assets/js/lib/utils.js | 14 +++ assets/js/session/index.js | 36 ++++-- assets/js/user_form/index.js | 20 +++ lib/livebook/session.ex | 38 ++++-- lib/livebook/session/data.ex | 81 ++++++++---- lib/livebook/users.ex | 20 +++ lib/livebook/users/user.ex | 109 ++++++++++++++++ lib/livebook_web/live/home_live.ex | 37 +++++- lib/livebook_web/live/session_live.ex | 103 +++++++++++---- .../live/session_live/attached_live.ex | 4 +- lib/livebook_web/live/user_component.ex | 98 +++++++++++++++ lib/livebook_web/live/user_helpers.ex | 61 +++++++++ lib/livebook_web/plugs/user_plug.ex | 58 +++++++++ lib/livebook_web/router.ex | 3 + test/livebook/session/data_test.exs | 119 +++++++++++++----- test/livebook/session_test.exs | 12 ++ test/livebook/users/user_test.exs | 34 +++++ test/livebook/users_test.exs | 17 +++ test/livebook_web/live/session_live_test.exs | 82 +++++++++++- test/livebook_web/plugs/user_plug_test.exs | 51 ++++++++ 24 files changed, 982 insertions(+), 96 deletions(-) create mode 100644 assets/js/lib/user.js create mode 100644 assets/js/user_form/index.js create mode 100644 lib/livebook/users.ex create mode 100644 lib/livebook/users/user.ex create mode 100644 lib/livebook_web/live/user_component.ex create mode 100644 lib/livebook_web/live/user_helpers.ex create mode 100644 lib/livebook_web/plugs/user_plug.ex create mode 100644 test/livebook/users/user_test.exs create mode 100644 test/livebook/users_test.exs create mode 100644 test/livebook_web/plugs/user_plug_test.exs diff --git a/assets/css/components.css b/assets/css/components.css index bdd839b89..77868e958 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -74,6 +74,10 @@ @apply w-full px-3 py-2 bg-gray-50 text-sm border border-gray-200 rounded-lg placeholder-gray-400 text-gray-600; } +.input-label { + @apply mb-0.5 text-sm text-gray-800 font-medium; +} + .switch-button { @apply relative inline-block w-14 h-7 mr-2 select-none transition; } diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index a859c8f99..f94e64c91 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -73,13 +73,28 @@ solely client-side operations. @apply bg-blue-300; } -[data-element="session"]:not([data-js-sections-panel-expanded]) - [data-element="sections-panel"] { +[data-element="session"]:not([data-js-side-panel-content]) + [data-element="side-panel"] { @apply hidden; } -[data-element="session"][data-js-sections-panel-expanded] - [data-element="sections-panel-toggle"] { +[data-element="session"]:not([data-js-side-panel-content="sections-list"]) + [data-element="sections-list"] { + @apply hidden; +} + +[data-element="session"]:not([data-js-side-panel-content="users-list"]) + [data-element="users-list"] { + @apply hidden; +} + +[data-element="session"][data-js-side-panel-content="sections-list"] + [data-element="sections-list-toggle"] { + @apply text-gray-50 bg-gray-700; +} + +[data-element="session"][data-js-side-panel-content="users-list"] + [data-element="users-list-toggle"] { @apply text-gray-50 bg-gray-700; } diff --git a/assets/js/app.js b/assets/js/app.js index 54e2f753b..233b236c4 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -18,7 +18,9 @@ import FocusOnUpdate from "./focus_on_update"; import ScrollOnUpdate from "./scroll_on_update"; import VirtualizedLines from "./virtualized_lines"; import Menu from "./menu"; +import UserForm from "./user_form"; import morphdomCallbacks from "./morphdom_callbacks"; +import { loadUserData } from "./lib/user"; const hooks = { ContentEditable, @@ -28,6 +30,7 @@ const hooks = { ScrollOnUpdate, VirtualizedLines, Menu, + UserForm, }; const csrfToken = document @@ -35,7 +38,13 @@ const csrfToken = document .getAttribute("content"); const liveSocket = new LiveSocket("/live", Socket, { - params: { _csrf_token: csrfToken }, + params: (liveViewName) => { + return { + _csrf_token: csrfToken, + // Pass the most recent user data to the LiveView in `connect_params` + user_data: loadUserData(), + }; + }, hooks: hooks, dom: morphdomCallbacks, }); diff --git a/assets/js/lib/user.js b/assets/js/lib/user.js new file mode 100644 index 000000000..7f4f33a1a --- /dev/null +++ b/assets/js/lib/user.js @@ -0,0 +1,43 @@ +import { decodeBase64, encodeBase64 } from "./utils"; + +const USER_DATA_COOKIE = "user_data"; + +/** + * Stores user data in the `"user_data"` cookie. + */ +export function storeUserData(userData) { + const json = JSON.stringify(userData); + const encoded = encodeBase64(json); + setCookie(USER_DATA_COOKIE, encoded, 157_680_000); // 5 years +} + +/** + * Loads user data from the `"user_data"` cookie. + */ +export function loadUserData() { + const encoded = getCookieValue(USER_DATA_COOKIE); + if (encoded) { + const json = decodeBase64(encoded); + return JSON.parse(json); + } else { + return null; + } +} + +function getCookieValue(key) { + const cookie = document.cookie + .split("; ") + .find((cookie) => cookie.startsWith(`${key}=`)); + + if (cookie) { + const value = cookie.replace(`${key}=`, ""); + return value; + } else { + return null; + } +} + +function setCookie(key, value, maxAge) { + const cookie = `${key}=${value};max-age=${maxAge};path=/`; + document.cookie = cookie; +} diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js index bf4f71b9e..3e1ea2fb6 100644 --- a/assets/js/lib/utils.js +++ b/assets/js/lib/utils.js @@ -49,3 +49,17 @@ export function smoothlyScrollToElement(element) { element.scrollIntoView({ behavior: "smooth", block: "start" }); } } + +/** + * Transforms a UTF8 string into base64 encoding. + */ +export function encodeBase64(string) { + return btoa(unescape(encodeURIComponent(string))); +} + +/** + * Transforms base64 encoding into UTF8 string. + */ +export function decodeBase64(binary) { + return decodeURIComponent(escape(atob(binary))); +} diff --git a/assets/js/session/index.js b/assets/js/session/index.js index 2e7433dff..6c869eb1e 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -47,8 +47,12 @@ const Session = { handleSectionListClick(this, event); }); - getSectionsPanelToggle().addEventListener("click", (event) => { - toggleSectionsPanel(this); + getSectionsListToggle().addEventListener("click", (event) => { + toggleSectionsList(this); + }); + + getUsersListToggle().addEventListener("click", (event) => { + toggleUsersList(this); }); getNotebook().addEventListener("scroll", (event) => { @@ -179,7 +183,9 @@ function handleDocumentKeyDown(hook, event) { } else if (keyBuffer.tryMatch(["e", "j"])) { queueChildCellsEvaluation(hook); } else if (keyBuffer.tryMatch(["s", "s"])) { - toggleSectionsPanel(hook); + toggleSectionsList(hook); + } else if (keyBuffer.tryMatch(["s", "u"])) { + toggleUsersList(hook); } else if (keyBuffer.tryMatch(["s", "r"])) { showNotebookRuntimeSettings(hook); } else if (keyBuffer.tryMatch(["e", "x"])) { @@ -336,8 +342,20 @@ function updateSectionListHighlight() { // User action handlers (mostly keybindings) -function toggleSectionsPanel(hook) { - hook.el.toggleAttribute("data-js-sections-panel-expanded"); +function toggleSectionsList(hook) { + if (hook.el.getAttribute("data-js-side-panel-content") === "sections-list") { + hook.el.removeAttribute("data-js-side-panel-content"); + } else { + hook.el.setAttribute("data-js-side-panel-content", "sections-list"); + } +} + +function toggleUsersList(hook) { + if (hook.el.getAttribute("data-js-side-panel-content") === "users-list") { + hook.el.removeAttribute("data-js-side-panel-content"); + } else { + hook.el.setAttribute("data-js-side-panel-content", "users-list"); + } } function showNotebookRuntimeSettings(hook) { @@ -639,8 +657,12 @@ function getNotebook() { return document.querySelector(`[data-element="notebook"]`); } -function getSectionsPanelToggle() { - return document.querySelector(`[data-element="sections-panel-toggle"]`); +function getSectionsListToggle() { + return document.querySelector(`[data-element="sections-list-toggle"]`); +} + +function getUsersListToggle() { + return document.querySelector(`[data-element="users-list-toggle"]`); } function cancelEvent(event) { diff --git a/assets/js/user_form/index.js b/assets/js/user_form/index.js new file mode 100644 index 000000000..e8766e7e6 --- /dev/null +++ b/assets/js/user_form/index.js @@ -0,0 +1,20 @@ +import { storeUserData } from "../lib/user"; + +/** + * A hook for the user profile form. + * + * On submit this hook saves the new data into cookie. + * This cookie serves as a backup and can be used to restore + * user data if the server is restarted. + */ +const UserForm = { + mounted() { + this.el.addEventListener("submit", (event) => { + const name = this.el.data_name.value; + const hex_color = this.el.data_hex_color.value; + storeUserData({ name, hex_color }); + }); + }, +}; + +export default UserForm; diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 34120f4ba..5bc9995d2 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -16,6 +16,7 @@ defmodule Livebook.Session do alias Livebook.Session.{Data, FileGuard} alias Livebook.{Utils, Notebook, Delta, Runtime, LiveMarkdown} + alias Livebook.Users.User alias Livebook.Notebook.{Cell, Section} @type state :: %{ @@ -82,9 +83,9 @@ defmodule Livebook.Session do keep in sync with the server by subscribing to the `sessions:id` topic and receiving operations to apply. """ - @spec register_client(id(), pid()) :: Data.t() - def register_client(session_id, pid) do - GenServer.call(name(session_id), {:register_client, pid}) + @spec register_client(id(), pid(), User.t()) :: Data.t() + def register_client(session_id, client_pid, user) do + GenServer.call(name(session_id), {:register_client, client_pid, user}) end @doc """ @@ -317,10 +318,10 @@ defmodule Livebook.Session do end @impl true - def handle_call({:register_client, pid}, _from, state) do - Process.monitor(pid) + def handle_call({:register_client, client_pid, user}, _from, state) do + Process.monitor(client_pid) - state = handle_operation(state, {:client_join, pid}) + state = handle_operation(state, {:client_join, client_pid, user}) {:reply, state.data, state} end @@ -474,7 +475,7 @@ defmodule Livebook.Session do def handle_info({:DOWN, _, :process, pid, _}, state) do state = - if pid in state.data.client_pids do + if Map.has_key?(state.data.clients_map, pid) do handle_operation(state, {:client_leave, pid}) else state @@ -505,6 +506,11 @@ defmodule Livebook.Session do {:noreply, maybe_save_notebook(state)} end + def handle_info({:user_change, user}, state) do + operation = {:update_user, self(), user} + {:noreply, handle_operation(state, operation)} + end + def handle_info(_message, state), do: {:noreply, state} @impl true @@ -606,6 +612,24 @@ defmodule Livebook.Session do state end + defp after_operation(state, prev_state, {:client_join, _client_pid, user}) do + unless Map.has_key?(prev_state.data.users_map, user.id) do + Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{user.id}") + end + + state + end + + defp after_operation(state, prev_state, {:client_leave, client_pid}) do + user_id = prev_state.data.clients_map[client_pid] + + unless Map.has_key?(state.data.users_map, user_id) do + Phoenix.PubSub.unsubscribe(Livebook.PubSub, "users:#{user_id}") + end + + state + end + defp after_operation(state, _prev_state, _operation), do: state defp handle_actions(state, actions) do diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index f251aeb75..0eb34d422 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -23,10 +23,12 @@ defmodule Livebook.Session.Data do :deleted_sections, :deleted_cells, :runtime, - :client_pids + :clients_map, + :users_map ] alias Livebook.{Notebook, Evaluator, Delta, Runtime, JSInterop} + alias Livebook.Users.User alias Livebook.Notebook.{Cell, Section} @type t :: %__MODULE__{ @@ -38,7 +40,8 @@ defmodule Livebook.Session.Data do deleted_sections: list(Section.t()), deleted_cells: list(Cell.t()), runtime: Runtime.t() | nil, - client_pids: list(pid()) + clients_map: %{pid() => User.id()}, + users_map: %{User.id() => User.t()} } @type section_info :: %{ @@ -61,6 +64,8 @@ defmodule Livebook.Session.Data do @type cell_validity_status :: :fresh | :evaluated | :stale | :aborted @type cell_evaluation_status :: :ready | :queued | :evaluating + @type client :: {User.id(), pid()} + @type index :: non_neg_integer() # Note that all operations carry the pid of whatever @@ -85,8 +90,9 @@ defmodule Livebook.Session.Data do | {:cancel_cell_evaluation, pid(), Cell.id()} | {:set_notebook_name, pid(), String.t()} | {:set_section_name, pid(), Section.id(), String.t()} - | {:client_join, pid()} + | {:client_join, pid(), User.t()} | {:client_leave, pid()} + | {:update_user, pid(), User.t()} | {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()} | {:report_cell_revision, pid(), Cell.id(), cell_revision()} | {:set_cell_metadata, pid(), Cell.id(), Cell.metadata()} @@ -114,7 +120,8 @@ defmodule Livebook.Session.Data do deleted_sections: [], deleted_cells: [], runtime: nil, - client_pids: [] + clients_map: %{}, + users_map: %{} } end @@ -128,7 +135,7 @@ defmodule Livebook.Session.Data do for section <- notebook.sections, cell <- section.cells, into: %{}, - do: {cell.id, new_cell_info(cell, [])} + do: {cell.id, new_cell_info(cell, %{})} end @doc """ @@ -334,22 +341,33 @@ defmodule Livebook.Session.Data do end end - def apply_operation(data, {:client_join, pid}) do - with false <- pid in data.client_pids do + def apply_operation(data, {:client_join, client_pid, user}) do + with false <- Map.has_key?(data.clients_map, client_pid) do data |> with_actions() - |> client_join(pid) + |> client_join(client_pid, user) |> wrap_ok() else _ -> :error end end - def apply_operation(data, {:client_leave, pid}) do - with true <- pid in data.client_pids do + def apply_operation(data, {:client_leave, client_pid}) do + with true <- Map.has_key?(data.clients_map, client_pid) do data |> with_actions() - |> client_leave(pid) + |> client_leave(client_pid) + |> wrap_ok() + else + _ -> :error + end + end + + def apply_operation(data, {:update_user, _client_pid, user}) do + with true <- Map.has_key?(data.users_map, user.id) do + data + |> with_actions() + |> update_user(user) |> wrap_ok() else _ -> :error @@ -360,7 +378,7 @@ defmodule Livebook.Session.Data do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), cell_info <- data.cell_infos[cell.id], true <- 0 < revision and revision <= cell_info.revision + 1, - true <- client_pid in data.client_pids do + true <- Map.has_key?(data.clients_map, client_pid) do data |> with_actions() |> apply_delta(client_pid, cell, delta, revision) @@ -375,7 +393,7 @@ defmodule Livebook.Session.Data do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), cell_info <- data.cell_infos[cell.id], true <- 0 < revision and revision <= cell_info.revision, - true <- client_pid in data.client_pids do + true <- Map.has_key?(data.clients_map, client_pid) do data |> with_actions() |> report_revision(client_pid, cell, revision) @@ -438,7 +456,7 @@ defmodule Livebook.Session.Data do data_actions |> set!( notebook: Notebook.insert_cell(data.notebook, section_id, index, cell), - cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info(cell, data.client_pids)) + cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info(cell, data.clients_map)) ) end @@ -682,23 +700,42 @@ defmodule Livebook.Session.Data do |> set!(notebook: Notebook.update_section(data.notebook, section.id, &%{&1 | name: name})) end - defp client_join({data, _} = data_actions, pid) do + defp client_join({data, _} = data_actions, client_pid, user) do data_actions - |> set!(client_pids: [pid | data.client_pids]) + |> set!( + clients_map: Map.put(data.clients_map, client_pid, user.id), + users_map: Map.put(data.users_map, user.id, user) + ) |> update_every_cell_info(fn info -> - put_in(info.revision_by_client_pid[pid], info.revision) + put_in(info.revision_by_client_pid[client_pid], info.revision) end) end - defp client_leave({data, _} = data_actions, pid) do + defp client_leave({data, _} = data_actions, client_pid) do + {user_id, clients_map} = Map.pop(data.clients_map, client_pid) + + users_map = + if user_id in Map.values(clients_map) do + data.users_map + else + Map.delete(data.users_map, user_id) + end + data_actions - |> set!(client_pids: List.delete(data.client_pids, pid)) + |> set!( + clients_map: clients_map, + users_map: users_map + ) |> update_every_cell_info(fn info -> - {_, info} = pop_in(info.revision_by_client_pid[pid]) + {_, info} = pop_in(info.revision_by_client_pid[client_pid]) purge_deltas(info) end) end + defp update_user({data, _} = data_actions, user) do + set!(data_actions, users_map: Map.put(data.users_map, user.id, user)) + end + defp apply_delta({data, _} = data_actions, client_pid, cell, delta, revision) do info = data.cell_infos[cell.id] @@ -777,7 +814,9 @@ defmodule Livebook.Session.Data do } end - defp new_cell_info(cell, client_pids) do + defp new_cell_info(cell, clients_map) do + client_pids = Map.keys(clients_map) + %{ revision: 0, deltas: [], diff --git a/lib/livebook/users.ex b/lib/livebook/users.ex new file mode 100644 index 000000000..c7abc3cc2 --- /dev/null +++ b/lib/livebook/users.ex @@ -0,0 +1,20 @@ +defmodule Livebook.Users do + @moduledoc false + + alias Livebook.Users.User + + @doc """ + Notifies interested processes about user data change. + + Broadcasts `{:user_change, user}` message under the `"user:{id}"` topic. + """ + @spec broadcast_change(User.t()) :: :ok + def broadcast_change(user) do + broadcast_user_message(user.id, {:user_change, user}) + :ok + end + + defp broadcast_user_message(user_id, message) do + Phoenix.PubSub.broadcast(Livebook.PubSub, "users:#{user_id}", message) + end +end diff --git a/lib/livebook/users/user.ex b/lib/livebook/users/user.ex new file mode 100644 index 000000000..bb492b7d9 --- /dev/null +++ b/lib/livebook/users/user.ex @@ -0,0 +1,109 @@ +defmodule Livebook.Users.User do + @moduledoc false + + # Represents a Livebook user. + # + # Livebook users are not regular web app accounts, + # but rather ephemeral data about the clients + # using the app. Every person using Livebook + # can provide data like name and cursor color + # to improve visibility during collaboration. + + defstruct [:id, :name, :hex_color] + + alias Livebook.Utils + + @type t :: %__MODULE__{ + id: id(), + name: String.t() | nil, + hex_color: hex_color() + } + + @type id :: Utils.id() + @type hex_color :: String.t() + + @doc """ + Generates a new user. + """ + @spec new() :: t() + def new() do + %__MODULE__{ + id: Utils.random_id(), + name: nil, + hex_color: random_hex_color() + } + end + + @doc """ + Validates `attrs` and returns an updated user. + + In case of validation errors `{:error, errors, user}` tuple + is returned, where `user` is partially updated by using + only the valid attributes. + """ + @spec change(t(), %{binary() => any()}) :: {:ok, t()} | {:error, list(String.t()), t()} + def change(user, attrs \\ %{}) do + {user, []} + |> change_name(attrs) + |> change_hex_color(attrs) + |> case do + {user, []} -> {:ok, user} + {user, errors} -> {:error, errors, user} + end + end + + defp change_name({user, errors}, %{"name" => ""}) do + {%{user | name: nil}, errors} + end + + defp change_name({user, errors}, %{"name" => name}) do + {%{user | name: name}, errors} + end + + defp change_name({user, errors}, _attrs), do: {user, errors} + + defp change_hex_color({user, errors}, %{"hex_color" => hex_color}) do + if hex_color_valid?(hex_color) do + {%{user | hex_color: hex_color}, errors} + else + {user, [{:hex_color, "not a valid color"} | errors]} + end + end + + defp change_hex_color({user, errors}, _attrs), do: {user, errors} + + defp hex_color_valid?(hex_color), do: hex_color =~ ~r/^#[0-9a-fA-F]{6}$/ + + @doc """ + Returns a random hex color for a user. + + ## Options + + * `:except` - a list of colors to omit + """ + def random_hex_color(opts \\ []) do + colors = [ + # red + "#F87171", + # yellow + "#FBBF24", + # green + "#6EE7B7", + # blue + "#60A5FA", + # purple + "#A78BFA", + # pink + "#F472B6", + # salmon + "#FA8072", + # mat green + "#9ED9CC" + ] + + except = opts[:except] || [] + colors = colors -- except + + Enum.random(colors) + end +end diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index ef0781b5b..2ee20d506 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -1,23 +1,41 @@ defmodule LivebookWeb.HomeLive do use LivebookWeb, :live_view + import LivebookWeb.UserHelpers + alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook} @impl true - def mount(_params, _session, socket) do + def mount(_params, %{"current_user_id" => current_user_id}, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions") + Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}") end + current_user = build_current_user(current_user_id, socket) session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries()) - {:ok, assign(socket, path: default_path(), session_summaries: session_summaries)} + {:ok, + assign(socket, + current_user: current_user, + path: default_path(), + session_summaries: session_summaries + )} end @impl true def render(assigns) do ~L"""
+
+
+ + <%= live_patch to: Routes.home_path(@socket, :user), + class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %> + <%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %> + <% end %> + +
@@ -99,6 +117,14 @@ defmodule LivebookWeb.HomeLive do
+ <%= if @live_action == :user do %> + <%= live_modal @socket, LivebookWeb.UserComponent, + id: :user_modal, + modal_class: "w-full max-w-sm", + user: @current_user, + return_to: Routes.home_path(@socket, :page) %> + <% end %> + <%= if @live_action == :close_session do %> <%= live_modal @socket, LivebookWeb.HomeLive.CloseSessionComponent, id: :close_session_modal, @@ -181,6 +207,13 @@ defmodule LivebookWeb.HomeLive do create_session(socket, notebook: notebook) end + def handle_info( + {:user_change, %{id: id} = user}, + %{assigns: %{current_user: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :current_user, user)} + end + def handle_info(_message, socket), do: {:noreply, socket} defp default_path(), do: Livebook.Config.root_path() <> "/" diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 8ef9f418a..942d5a24b 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -1,15 +1,20 @@ defmodule LivebookWeb.SessionLive do use LivebookWeb, :live_view + import LivebookWeb.UserHelpers + alias Livebook.{SessionSupervisor, Session, Delta, Notebook, Runtime} @impl true - def mount(%{"id" => session_id}, _session, socket) do + def mount(%{"id" => session_id}, %{"current_user_id" => current_user_id}, socket) do if SessionSupervisor.session_exists?(session_id) do + current_user = build_current_user(current_user_id, socket) + data = if connected?(socket) do - data = Session.register_client(session_id, self()) + data = Session.register_client(session_id, self(), current_user) Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}") + Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}") data else @@ -26,6 +31,7 @@ defmodule LivebookWeb.SessionLive do platform: platform, session_id: session_id, session_pid: session_pid, + current_user: current_user, data_view: data_to_view(data) ) |> assign_private(data: data) @@ -63,15 +69,20 @@ defmodule LivebookWeb.SessionLive do id="session" data-element="session" phx-hook="Session"> -
+
<%= live_patch to: Routes.home_path(@socket, :page) do %> livebook <% end %> - + + + <%= live_patch to: Routes.session_path(@socket, :runtime_settings, @session_id), class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(@live_action == :runtime_settings, do: "text-gray-50 bg-gray-700")}" do %> @@ -85,27 +96,55 @@ defmodule LivebookWeb.SessionLive do <%= remix_icon("keyboard-box-fill", class: "text-2xl") %> <% end %> + + <%= live_patch to: Routes.session_path(@socket, :user, @session_id), + class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %> + <%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %> + <% end %> +
-
-

- Sections -

-
- <%= for section_item <- @data_view.sections_items do %> - - <% end %> + data-element="side-panel"> +
+
+

+ Sections +

+
+ <%= for section_item <- @data_view.sections_items do %> + + <% end %> +
+ +
+
+
+
+

+ Users +

+

+ <%= length(@data_view.users) %> connected +

+
+ <%= for user <- @data_view.users do %> +
+ <%= render_user_avatar(user, class: "h-7 w-7 flex-shrink-0", text_class: "text-xs") %> + + <%= user.name || "Anonymous" %> + +
+ <% end %> +
-
@@ -170,6 +209,14 @@ defmodule LivebookWeb.SessionLive do
+ <%= if @live_action == :user do %> + <%= live_modal @socket, LivebookWeb.UserComponent, + id: :user_modal, + modal_class: "w-full max-w-sm", + user: @current_user, + return_to: Routes.session_path(@socket, :page, @session_id) %> + <% end %> + <%= if @live_action == :runtime_settings do %> <%= live_modal @socket, LivebookWeb.SessionLive.RuntimeComponent, id: :runtime_settings_modal, @@ -495,6 +542,13 @@ defmodule LivebookWeb.SessionLive do {:noreply, push_event(socket, "completion_response", payload)} end + def handle_info( + {:user_change, %{id: id} = user}, + %{assigns: %{current_user: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :current_user, user)} + end + def handle_info(_message, socket), do: {:noreply, socket} defp after_operation(socket, _prev_socket, {:insert_section, client_pid, _index, section_id}) do @@ -606,6 +660,11 @@ defmodule LivebookWeb.SessionLive do for section <- data.notebook.sections do %{id: section.id, name: section.name} end, + users: + data.clients_map + |> Map.values() + |> Enum.map(&data.users_map[&1]) + |> Enum.sort_by(& &1.name), section_views: Enum.map(data.notebook.sections, §ion_to_view(&1, data)) } end diff --git a/lib/livebook_web/live/session_live/attached_live.ex b/lib/livebook_web/live/session_live/attached_live.ex index 286b0995e..a261670e9 100644 --- a/lib/livebook_web/live/session_live/attached_live.ex +++ b/lib/livebook_web/live/session_live/attached_live.ex @@ -42,12 +42,12 @@ defmodule LivebookWeb.SessionLive.AttachedLive do <%= f = form_for :data, "#", phx_submit: "init", phx_change: "validate" %>
-
Name
+
Name
<%= text_input f, :name, value: @data["name"], class: "input", placeholder: if(Livebook.Config.shortnames?, do: "test", else: "test@127.0.0.1") %>
-
Cookie
+
Cookie
<%= text_input f, :cookie, value: @data["cookie"], class: "input", placeholder: "mycookie" %>
diff --git a/lib/livebook_web/live/user_component.ex b/lib/livebook_web/live/user_component.ex new file mode 100644 index 000000000..36ff3370e --- /dev/null +++ b/lib/livebook_web/live/user_component.ex @@ -0,0 +1,98 @@ +defmodule LivebookWeb.UserComponent do + use LivebookWeb, :live_component + + import LivebookWeb.UserHelpers + + alias Livebook.Users.User + + @impl true + def update(assigns, socket) do + socket = assign(socket, assigns) + user = socket.assigns.user + + {:ok, assign(socket, data: user_to_data(user), valid: true, preview_user: user)} + end + + defp user_to_data(user) do + %{"name" => user.name || "", "hex_color" => user.hex_color} + end + + @impl true + def render(assigns) do + ~L""" +
+

+ User profile +

+
+ <%= render_user_avatar(@preview_user, class: "h-20 w-20", text_class: "text-3xl") %> +
+ <%= f = form_for :data, "#", + id: "user_form", + phx_target: @myself, + phx_submit: "save", + phx_change: "validate", + phx_hook: "UserForm" %> +
+
+
Display name
+ <%= text_input f, :name, value: @data["name"], class: "input", spellcheck: "false" %> +
+
+
Cursor color
+
+
+
+
+
+
+ <%= text_input f, :hex_color, value: @data["hex_color"], class: "input", spellcheck: "false", maxlength: 7 %> + <%= tag :button, class: "icon-button absolute right-2 top-1", + type: "button", + phx_click: "randomize_color", + phx_target: @myself %> + <%= remix_icon("refresh-line", class: "text-xl") %> + +
+
+
+ <%= tag :button, class: "button button-blue flex space-x-1 justify-center items-center", + type: "submit", + disabled: not @valid %> + <%= remix_icon("save-line") %> + Save + +
+ +
+ """ + end + + @impl true + def handle_event("randomize_color", %{}, socket) do + data = %{ + socket.assigns.data + | "hex_color" => User.random_hex_color(except: [socket.assigns.preview_user.hex_color]) + } + + handle_event("validate", %{"data" => data}, socket) + end + + def handle_event("validate", %{"data" => data}, socket) do + {valid, user} = + case User.change(socket.assigns.user, data) do + {:ok, user} -> {true, user} + {:error, _errors, user} -> {false, user} + end + + {:noreply, assign(socket, data: data, valid: valid, preview_user: user)} + end + + def handle_event("save", %{"data" => data}, socket) do + {:ok, user} = User.change(socket.assigns.user, data) + Livebook.Users.broadcast_change(user) + {:noreply, push_patch(socket, to: socket.assigns.return_to)} + end +end diff --git a/lib/livebook_web/live/user_helpers.ex b/lib/livebook_web/live/user_helpers.ex new file mode 100644 index 000000000..38eec70d2 --- /dev/null +++ b/lib/livebook_web/live/user_helpers.ex @@ -0,0 +1,61 @@ +defmodule LivebookWeb.UserHelpers do + import Phoenix.LiveView + import Phoenix.LiveView.Helpers + + alias Livebook.Users.User + + @doc """ + Renders user avatar, + + ## Options + + * `:class` - class added to the avatar box + + * `:text_class` - class added to the avatar text + """ + def render_user_avatar(user, opts \\ []) do + assigns = %{ + name: user.name, + hex_color: user.hex_color, + class: Keyword.get(opts, :class, "w-full h-full"), + text_class: Keyword.get(opts, :text_class) + } + + ~L""" +
+
+ <%= avatar_text(@name) %> +
+
+ """ + end + + defp avatar_text(nil), do: "?" + + defp avatar_text(name) do + name + |> String.split() + |> Enum.map(&String.at(&1, 0)) + |> Enum.map(&String.upcase/1) + |> case do + [initial] -> initial + initials -> List.first(initials) <> List.last(initials) + end + end + + @doc """ + Builds `Livebook.Users.User` with the given user id. + + Uses `user_data` from socket `connect_params` as initial + attributes if the socket is connected. + """ + def build_current_user(current_user_id, socket) do + connect_params = get_connect_params(socket) || %{} + user_data = connect_params["user_data"] || %{} + + case User.change(%{User.new() | id: current_user_id}, user_data) do + {:ok, user} -> user + {:error, _errors, user} -> user + end + end +end diff --git a/lib/livebook_web/plugs/user_plug.ex b/lib/livebook_web/plugs/user_plug.ex new file mode 100644 index 000000000..90ffa5077 --- /dev/null +++ b/lib/livebook_web/plugs/user_plug.ex @@ -0,0 +1,58 @@ +defmodule LivebookWeb.UserPlug do + @moduledoc false + + # Initializes the session and cookies with user-related info. + # + # The first time someone visits Livebook + # this plug stores a new random user id + # in the session under `:current_user_id`. + # + # Additionally the cookies are checked for the presence + # of `"user_data"` and if there is none, a new user + # attributes are stored there. This makes sure + # the client-side can always access some `"user_data"` + # for `connect_params` of the socket connection. + + @behaviour Plug + + import Plug.Conn + + alias Livebook.Users.User + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + conn + |> ensure_current_user_id() + |> ensure_user_data() + end + + defp ensure_current_user_id(conn) do + if get_session(conn, :current_user_id) do + conn + else + user_id = Livebook.Utils.random_id() + put_session(conn, :current_user_id, user_id) + end + end + + defp ensure_user_data(conn) do + if Map.has_key?(conn.req_cookies, "user_data") do + conn + else + user_data = user_data(User.new()) + encoded = user_data |> Jason.encode!() |> Base.encode64() + # Set `http_only` to `false`, so that it can be accessed on the client + # Set expiration in 5 years + put_resp_cookie(conn, "user_data", encoded, http_only: false, max_age: 157_680_000) + end + end + + defp user_data(user) do + user + |> Map.from_struct() + |> Map.delete(:id) + end +end diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index c301685ec..46c8e9644 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -13,15 +13,18 @@ defmodule LivebookWeb.Router do pipeline :auth do plug LivebookWeb.AuthPlug + plug LivebookWeb.UserPlug end scope "/", LivebookWeb do pipe_through [:browser, :auth] live "/", HomeLive, :page + live "/home/user-profile", HomeLive, :user live "/home/import/:tab", HomeLive, :import live "/home/sessions/:session_id/close", HomeLive, :close_session live "/sessions/:id", SessionLive, :page + live "/sessions/:id/user-profile", SessionLive, :user live "/sessions/:id/shortcuts", SessionLive, :shortcuts live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings live "/sessions/:id/settings/file", SessionLive, :file_settings diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs index d0a1ee31a..1e7673eab 100644 --- a/test/livebook/session/data_test.exs +++ b/test/livebook/session/data_test.exs @@ -3,6 +3,7 @@ defmodule Livebook.Session.DataTest do alias Livebook.Session.Data alias Livebook.{Delta, Notebook} + alias Livebook.Users.User describe "new/1" do test "called with no arguments defaults to a blank notebook" do @@ -72,7 +73,7 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client_pid}, + {:client_join, client_pid, User.new()}, {:insert_section, self(), 0, "s1"} ]) @@ -1193,37 +1194,43 @@ defmodule Livebook.Session.DataTest do describe "apply_operation/2 given :client_join" do test "returns an error if the given process is already a client" do + user = User.new() + data = data_after_operations!([ - {:client_join, self()} + {:client_join, self(), user} ]) - operation = {:client_join, self()} + operation = {:client_join, self(), user} assert :error = Data.apply_operation(data, operation) end - test "adds the given process to the client list" do + test "adds the given process and user to their corresponding maps" do client_pid = self() + %{id: user_id} = user = User.new() data = Data.new() - operation = {:client_join, client_pid} - assert {:ok, %{client_pids: [^client_pid]}, []} = Data.apply_operation(data, operation) + operation = {:client_join, client_pid, user} + + assert {:ok, %{clients_map: %{^client_pid => ^user_id}, users_map: %{^user_id => ^user}}, + []} = Data.apply_operation(data, operation) end test "adds new entry to the cell revisions map for the client with the latest revision" do client1_pid = IEx.Helpers.pid(0, 0, 0) + user = User.new() delta1 = Delta.new() |> Delta.insert("cats") data = data_after_operations!([ - {:client_join, client1_pid}, + {:client_join, client1_pid, user}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"}, {:apply_cell_delta, client1_pid, "c1", delta1, 1} ]) client2_pid = IEx.Helpers.pid(0, 0, 1) - operation = {:client_join, client2_pid} + operation = {:client_join, client2_pid, user} assert {:ok, %{ @@ -1240,14 +1247,44 @@ defmodule Livebook.Session.DataTest do assert :error = Data.apply_operation(data, operation) end - test "removes the given process from the client list" do + test "removes the given process from the client map" do data = data_after_operations!([ - {:client_join, self()} + {:client_join, self(), User.new()} ]) operation = {:client_leave, self()} - assert {:ok, %{client_pids: []}, []} = Data.apply_operation(data, operation) + + empty_map = %{} + assert {:ok, %{clients_map: ^empty_map}, []} = Data.apply_operation(data, operation) + end + + test "removes the corresponding user from users map if it has no more client processes" do + data = + data_after_operations!([ + {:client_join, self(), User.new()} + ]) + + operation = {:client_leave, self()} + + empty_map = %{} + assert {:ok, %{users_map: ^empty_map}, []} = Data.apply_operation(data, operation) + end + + test "leaves the corresponding user in users map if it has more client processes" do + %{id: user_id} = user = User.new() + client1_pid = IEx.Helpers.pid(0, 0, 0) + client2_pid = IEx.Helpers.pid(0, 0, 1) + + data = + data_after_operations!([ + {:client_join, client1_pid, user}, + {:client_join, client2_pid, user} + ]) + + operation = {:client_leave, client2_pid} + + assert {:ok, %{users_map: %{^user_id => ^user}}, []} = Data.apply_operation(data, operation) end test "removes an entry in the the cell revisions map for the client and purges deltas" do @@ -1258,8 +1295,8 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client1_pid}, - {:client_join, client2_pid}, + {:client_join, client1_pid, User.new()}, + {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"}, {:apply_cell_delta, client1_pid, "c1", delta1, 1} @@ -1278,11 +1315,35 @@ defmodule Livebook.Session.DataTest do end end + describe "apply_operation/2 given :update_user" do + test "returns an error if the given user is not a client" do + data = Data.new() + + operation = {:update_user, self(), User.new()} + assert :error = Data.apply_operation(data, operation) + end + + test "updates users map" do + %{id: user_id} = user = User.new() + + data = + data_after_operations!([ + {:client_join, self(), user} + ]) + + updated_user = %{user | name: "Jake Peralta"} + operation = {:update_user, self(), updated_user} + + assert {:ok, %{users_map: %{^user_id => ^updated_user}}, []} = + Data.apply_operation(data, operation) + end + end + describe "apply_operation/2 given :apply_cell_delta" do test "returns an error given invalid cell id" do data = data_after_operations!([ - {:client_join, self()} + {:client_join, self(), User.new()} ]) operation = {:apply_cell_delta, self(), "nonexistent", Delta.new(), 1} @@ -1304,7 +1365,7 @@ defmodule Livebook.Session.DataTest do test "returns an error given invalid revision" do data = data_after_operations!([ - {:client_join, self()}, + {:client_join, self(), User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"} ]) @@ -1318,7 +1379,7 @@ defmodule Livebook.Session.DataTest do test "updates cell source according to the given delta" do data = data_after_operations!([ - {:client_join, self()}, + {:client_join, self(), User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"} ]) @@ -1340,7 +1401,7 @@ defmodule Livebook.Session.DataTest do test "updates cell digest based on the new content" do data = data_after_operations!([ - {:client_join, self()}, + {:client_join, self(), User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"} ]) @@ -1364,8 +1425,8 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client1_pid}, - {:client_join, client2_pid}, + {:client_join, client1_pid, User.new()}, + {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"}, {:apply_cell_delta, client1_pid, "c1", delta1, 1} @@ -1393,8 +1454,8 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client1_pid}, - {:client_join, client2_pid}, + {:client_join, client1_pid, User.new()}, + {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"}, {:apply_cell_delta, client1_pid, "c1", delta1, 1} @@ -1414,7 +1475,7 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client_pid}, + {:client_join, client_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"} ]) @@ -1434,8 +1495,8 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client1_pid}, - {:client_join, client2_pid}, + {:client_join, client1_pid, User.new()}, + {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"} ]) @@ -1454,7 +1515,7 @@ defmodule Livebook.Session.DataTest do test "returns an error given invalid cell id" do data = data_after_operations!([ - {:client_join, self()} + {:client_join, self(), User.new()} ]) operation = {:report_cell_revision, self(), "nonexistent", 1} @@ -1467,7 +1528,7 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client1_pid}, + {:client_join, client1_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"}, {:apply_cell_delta, client1_pid, "c1", Delta.new(insert: "cats"), 1} @@ -1480,7 +1541,7 @@ defmodule Livebook.Session.DataTest do test "returns an error given invalid revision" do data = data_after_operations!([ - {:client_join, self()}, + {:client_join, self(), User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"} ]) @@ -1497,8 +1558,8 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!([ - {:client_join, client1_pid}, - {:client_join, client2_pid}, + {:client_join, client1_pid, User.new()}, + {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :elixir, "c1"}, {:apply_cell_delta, client1_pid, "c1", delta1, 1} diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs index fe709b92c..8491c7a71 100644 --- a/test/livebook/session_test.exs +++ b/test/livebook/session_test.exs @@ -372,6 +372,18 @@ defmodule Livebook.SessionTest do assert_receive {:info, "runtime node terminated unexpectedly"} end + test "on user change sends an update operation subscribers", %{session_id: session_id} do + user = Livebook.Users.User.new() + Session.register_client(session_id, self(), user) + + Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}") + + updated_user = %{user | name: "Jake Peralta"} + Livebook.Users.broadcast_change(updated_user) + + assert_receive {:operation, {:update_user, _pid, ^updated_user}} + end + defp start_session(opts \\ []) do session_id = Utils.random_id() {:ok, _} = Session.start_link(Keyword.merge(opts, id: session_id)) diff --git a/test/livebook/users/user_test.exs b/test/livebook/users/user_test.exs new file mode 100644 index 000000000..2eb3358b7 --- /dev/null +++ b/test/livebook/users/user_test.exs @@ -0,0 +1,34 @@ +defmodule Livebook.Users.UserTest do + use ExUnit.Case, async: true + + alias Livebook.Users.User + + describe "change/2" do + test "given valid attributes returns and updated user" do + user = User.new() + attrs = %{"name" => "Jake Peralta", "hex_color" => "#000000"} + assert {:ok, %User{name: "Jake Peralta", hex_color: "#000000"}} = User.change(user, attrs) + end + + test "given empty name sets name to nil" do + user = User.new() + attrs = %{"name" => ""} + assert {:ok, %User{name: nil}} = User.change(user, attrs) + end + + test "given invalid color returns an error" do + user = User.new() + attrs = %{"hex_color" => "#invalid"} + assert {:error, [{:hex_color, "not a valid color"}], _user} = User.change(user, attrs) + end + + test "given invalid attribute partially updates the user" do + user = User.new() + current_hex_color = user.hex_color + attrs = %{"hex_color" => "#invalid", "name" => "Jake Peralta"} + + assert {:error, _errors, %User{name: "Jake Peralta", hex_color: ^current_hex_color}} = + User.change(user, attrs) + end + end +end diff --git a/test/livebook/users_test.exs b/test/livebook/users_test.exs new file mode 100644 index 000000000..92807472f --- /dev/null +++ b/test/livebook/users_test.exs @@ -0,0 +1,17 @@ +defmodule Livebook.UsersTest do + use ExUnit.Case, async: true + + alias Livebook.Users + alias Livebook.Users.User + + describe "broadcast_change/1" do + test "notifies subscribers of user change" do + user = User.new() + Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{user.id}") + + Users.broadcast_change(user) + + assert_received {:user_change, ^user} + end + end +end diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index cd3fe4df1..be63cc5e0 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -3,7 +3,8 @@ defmodule LivebookWeb.SessionLiveTest do import Phoenix.LiveViewTest - alias Livebook.{SessionSupervisor, Session, Delta, Runtime} + alias Livebook.{SessionSupervisor, Session, Delta, Runtime, Users} + alias Livebook.Users.User setup do {:ok, session_id} = SessionSupervisor.create_session() @@ -270,6 +271,80 @@ defmodule LivebookWeb.SessionLiveTest do assert render(view) =~ "My notebook - fork" end + describe "connected users" do + test "lists connected users", %{conn: conn, session_id: session_id} do + user1 = create_user_with_name("Jake Peralta") + + client_pid = + spawn_link(fn -> + Session.register_client(session_id, self(), user1) + + receive do + :stop -> :ok + end + end) + + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + assert render(view) =~ "Jake Peralta" + + send(client_pid, :stop) + end + + test "updates users list whenever a user joins or leaves", + %{conn: conn, session_id: session_id} do + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}") + + user1 = create_user_with_name("Jake Peralta") + + client_pid = + spawn_link(fn -> + Session.register_client(session_id, self(), user1) + + receive do + :stop -> :ok + end + end) + + assert_receive {:operation, {:client_join, ^client_pid, _user}} + assert render(view) =~ "Jake Peralta" + + send(client_pid, :stop) + assert_receive {:operation, {:client_leave, ^client_pid}} + refute render(view) =~ "Jake Peralta" + end + + test "updates users list whenever a user changes his data", + %{conn: conn, session_id: session_id} do + user1 = create_user_with_name("Jake Peralta") + + client_pid = + spawn_link(fn -> + Session.register_client(session_id, self(), user1) + + receive do + :stop -> :ok + end + end) + + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}") + + assert render(view) =~ "Jake Peralta" + + Users.broadcast_change(%{user1 | name: "Raymond Holt"}) + assert_receive {:operation, {:update_user, _pid, _user}} + + refute render(view) =~ "Jake Peralta" + assert render(view) =~ "Raymond Holt" + + send(client_pid, :stop) + end + end + # Helpers defp wait_for_session_update(session_id) do @@ -297,4 +372,9 @@ defmodule LivebookWeb.SessionLiveTest do cell.id end + + defp create_user_with_name(name) do + {:ok, user} = User.new() |> User.change(%{"name" => name}) + user + end end diff --git a/test/livebook_web/plugs/user_plug_test.exs b/test/livebook_web/plugs/user_plug_test.exs new file mode 100644 index 000000000..ff92e7f92 --- /dev/null +++ b/test/livebook_web/plugs/user_plug_test.exs @@ -0,0 +1,51 @@ +defmodule LivebookWeb.UserPlugTest do + use ExUnit.Case, async: true + use Plug.Test + + defp call(conn) do + LivebookWeb.UserPlug.call(conn, LivebookWeb.UserPlug.init([])) + end + + test "given no user id in the session, generates a new user id" do + conn = + conn(:get, "/") + |> init_test_session(%{}) + |> call() + + assert get_session(conn, :current_user_id) != nil + end + + test "keeps user id in the session if present" do + conn = + conn(:get, "/") + |> init_test_session(%{current_user_id: "valid_user_id"}) + |> call() + + assert get_session(conn, :current_user_id) != nil + end + + test "given no user_data cookie, generates and stores new data" do + conn = + conn(:get, "/") + |> init_test_session(%{}) + |> fetch_cookies() + |> call() + |> fetch_cookies() + + assert conn.cookies["user_data"] != nil + end + + test "keeps user_data cookie if present" do + cookie_value = ~s/{"name":"Jake Peralta","hex_color":"#000000"}/ + + conn = + conn(:get, "/") + |> init_test_session(%{}) + |> put_req_cookie("user_data", cookie_value) + |> fetch_cookies() + |> call() + |> fetch_cookies() + + assert conn.cookies["user_data"] == cookie_value + end +end