From 0291d1efeb58e3e9c6bcca0c05b8c5328f55bad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wo=CC=88ginger?= Date: Thu, 15 Aug 2024 21:34:10 +0200 Subject: [PATCH] implement user session storage for liveviews taken from https://fly.io/phoenix-files/saving-and-restoring-liveview-state/ --- assets/js/app.js | 33 ++++---- assets/js/hooks/local_state_store.js | 23 +++++ lib/radiator_web/live/episode_live/index.ex | 83 +++++++++++++++++++ .../live/episode_live/index.html.heex | 2 +- 4 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 assets/js/hooks/local_state_store.js diff --git a/assets/js/app.js b/assets/js/app.js index 24962d0e..d0c80776 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -16,32 +16,37 @@ // // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html" +import "phoenix_html"; // Establish Phoenix Socket and LiveView configuration. -import { Socket } from "phoenix" -import { LiveSocket } from "phoenix_live_view" -import topbar from "../vendor/topbar" +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import topbar from "../vendor/topbar"; -import { Hooks } from "./hooks" +import { Hooks } from "./hooks"; +import * as LocalStateStore from "./hooks/local_state_store"; + +let csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute("content"); + +Hooks.LocalStateStore = LocalStateStore.hooks; -let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, longPollFallbackMs: 2500, - params: { _csrf_token: csrfToken } -}) + 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(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); +window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); // connect if there are any LiveViews on the page -liveSocket.connect() +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 - +window.liveSocket = liveSocket; diff --git a/assets/js/hooks/local_state_store.js b/assets/js/hooks/local_state_store.js new file mode 100644 index 00000000..c29f8682 --- /dev/null +++ b/assets/js/hooks/local_state_store.js @@ -0,0 +1,23 @@ +// JS Hook for storing some state in sessionStorage in the browser. +// The server requests stored data and clears it when requested. +// Code taken from fly.io blog post: https://fly.io/phoenix-files/saving-and-restoring-liveview-state/ +export const hooks = { + mounted() { + this.handleEvent("store", (obj) => this.store(obj)); + this.handleEvent("clear", (obj) => this.clear(obj)); + this.handleEvent("restore", (obj) => this.restore(obj)); + }, + + store(obj) { + sessionStorage.setItem(obj.key, obj.data); + }, + + restore(obj) { + var data = sessionStorage.getItem(obj.key); + this.pushEvent(obj.event, data); + }, + + clear(obj) { + sessionStorage.removeItem(obj.key); + }, +}; diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index d0a10040..bf3dfac4 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -1,5 +1,6 @@ defmodule RadiatorWeb.EpisodeLive.Index do use RadiatorWeb, :live_view + require Logger alias Radiator.Outline.Dispatch @@ -41,12 +42,94 @@ defmodule RadiatorWeb.EpisodeLive.Index do Dispatch.subscribe(episode.id) end + socket = + if connected?(socket) do + # TODO: Not too sure wether we should use one key for all episodes or one key per episode. + storage_key = "radiator-episode-#{episode.id}" + + socket + |> assign(:user_session_info, storage_key) + # request the browser to restore any state it has for this key. + |> push_event("restore", %{key: storage_key, event: "restoreSettings"}) + else + socket + end + socket |> assign(:selected_episode, episode) |> reply(:noreply) end + defp restore_from_token(nil), do: {:ok, nil} + + defp restore_from_token(token) do + salt = Application.get_env(:radiator, RadiatorWeb.Endpoint)[:live_view][:signing_salt] + # Max age is 1 day. 86,400 seconds + case Phoenix.Token.decrypt(RadiatorWeb.Endpoint, salt, token, max_age: 86_400) do + {:ok, data} -> + {:ok, data} + + {:error, reason} -> + # handles `:invalid`, `:expired` and possibly other things? + {:error, "Failed to restore previous state. Reason: #{inspect(reason)}."} + end + end + + defp serialize_to_token(state_data) do + salt = Application.get_env(:radiator, RadiatorWeb.Endpoint)[:live_view][:signing_salt] + Phoenix.Token.encrypt(RadiatorWeb.Endpoint, salt, state_data) + end + + # Push a websocket event down to the browser's JS hook. + # Clear any settings for the current my_storage_key. + defp clear_browser_storage(socket) do + push_event(socket, "clear", %{key: socket.assigns.my_storage_key}) + end + @impl true + # Pushed from JS hook. Server requests it to send up any + # stored settings for the key. + def handle_event("restoreSettings", token_data, socket) when is_binary(token_data) do + socket = + case restore_from_token(token_data) do + {:ok, nil} -> + # do nothing with the previous state + socket + + {:ok, restored} -> + socket + |> assign(:state, restored) + + {:error, reason} -> + # We don't continue checking. Display error. + # Clear the token so it doesn't keep showing an error. + socket + |> put_flash(:error, reason) + |> clear_browser_storage() + end + + {:noreply, socket} + end + + def handle_event("restoreSettings", _token_data, socket) do + # No expected token data received from the client + Logger.debug("No LiveView SessionStorage state to restore") + {:noreply, socket} + end + + def handle_event("something_happened_and_i_want_to_store", _params, socket) do + state_to_store = socket.assigns.state + + socket = + socket + |> push_event("store", %{ + key: socket.assigns.my_storage_key, + data: serialize_to_token(state_to_store) + }) + + {:noreply, socket} + end + def handle_event("new_episode", _params, socket) do show = socket.assigns.show number = Podcast.get_next_episode_number(show.id) diff --git a/lib/radiator_web/live/episode_live/index.html.heex b/lib/radiator_web/live/episode_live/index.html.heex index c86d3560..7b27a3ee 100644 --- a/lib/radiator_web/live/episode_live/index.html.heex +++ b/lib/radiator_web/live/episode_live/index.html.heex @@ -1,4 +1,4 @@ -
+