Skip to content

Commit

Permalink
implement user session storage for liveviews
Browse files Browse the repository at this point in the history
  • Loading branch information
electronicbites committed Aug 27, 2024
1 parent 4b7c2a5 commit 0291d1e
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 15 deletions.
33 changes: 19 additions & 14 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
23 changes: 23 additions & 0 deletions assets/js/hooks/local_state_store.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
83 changes: 83 additions & 0 deletions lib/radiator_web/live/episode_live/index.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule RadiatorWeb.EpisodeLive.Index do
use RadiatorWeb, :live_view
require Logger

alias Radiator.Outline.Dispatch

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/radiator_web/live/episode_live/index.html.heex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="grid grid-cols-12 gap-24">
<div class="grid grid-cols-12 gap-24" id="main-grid" phx-hook="LocalStateStore">
<aside class="col-span-12 sm:col-span-4">
<button
:if={@action != :new_episode}
Expand Down

0 comments on commit 0291d1e

Please sign in to comment.