Skip to content

Instantly share code, notes, and snippets.

@pootsbook
Last active October 23, 2020 18:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pootsbook/aa8ad6da28e797ac84dd74a7e723f00e to your computer and use it in GitHub Desktop.
Save pootsbook/aa8ad6da28e797ac84dd74a7e723f00e to your computer and use it in GitHub Desktop.
LiveView Chat
// We need to import the CSS so that webpack will load it.
// The MiniCssExtractPlugin is used to separate it out into
// its own CSS file.
import "../css/app.scss"
// webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
// in "webpack.config.js".
//
// Import deps with the dep name or local files with a relative path, for example:
//
// import {Socket} from "phoenix"
// import socket from "./socket"
//
import "phoenix_html"
import {Socket} from "phoenix"
import NProgress from "nprogress"
import {LiveSocket} from "phoenix_live_view"
let Hooks = {}
// https://github.com/phoenixframework/phoenix_live_view/issues/624
Hooks.BodyInput = {
updated(){
this.el.value = this.el.dataset["pending-val"]
this.el.focus()
}
}
// Hook to scroll the text after a message has been added
// element.scrollTop = where the scroll is currently
// element.offsetHeight = height of the scrolled container (inc. border + padding)
// element.clientHeight = height of the scrolled container (inc. padding)
// element.scrollTo(x, y) = x is upper left of scroll position, i.e. where scrollTop will be
// element.scrollTo(top: x, left: y, behavior: smooth|auto)
// element.scrollHeight = total height of scroll
// e.g. with a container of 840, and a height of 40, if it is scrolled to the bottom, the scrollTop will be 440
// logic: monitor scroll behaviour toggling a property "isBottom"; when a new message comes in, if the isBottom then scroll down. Future manual scroll behaviour will trigger `isBottom: false`
// https://stackoverflow.com/questions/39729791/chat-box-auto-scroll-to-bottom
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
// Show progress bar on live navigation and form submits
window.addEventListener("phx:page-loading-start", info => NProgress.start())
window.addEventListener("phx:page-loading-stop", info => NProgress.done())
// 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
defmodule Ontmoeting.Chat do
use GenServer
alias Ontmoeting.Message
@topic "chat"
# Client
def start_link(queue) do
GenServer.start_link(__MODULE__, queue, name: Ontmoeting.Chat)
end
def push_message(message) do
GenServer.cast(__MODULE__, {:push, message})
end
def list_messages() do
GenServer.call(__MODULE__, :list)
end
# Server (callbacks)
@impl true
def init(queue) do
{:ok, queue}
end
@impl true
def handle_cast({:push, message}, queue) do
timed_message = Map.merge(%Message{ time: NaiveDateTime.utc_now }, message)
state = Qex.push(queue, timed_message)
OntmoetingWeb.Endpoint.broadcast_from(self(), @topic, "message", %{messages: state})
{:noreply, state}
end
@impl true
def handle_call(:list, _from, queue) do
{:reply, queue, queue}
end
end
defmodule OntmoetingWeb.ChatLive do
use OntmoetingWeb, :live_view
@topic "chat"
alias Ontmoeting.Chat
alias Ontmoeting.Message
alias OntmoetingWeb.Presence
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Ontmoeting.PubSub, @topic)
end
users = Presence.list(@topic)
|> Map.keys
socket = assign(socket,
messages: Chat.list_messages(),
user: nil,
users: users)
{:ok, socket}
end
def render(assigns) do
~L"""
<h1>Chat</h1>
<h3>Members</h3>
<ul>
<%= for user <- @users do %>
<li>
<%= user %>
</li>
<% end %>
</ul>
<h3>Conversation</h3>
<dl style="max-height: 400px; overflow: auto">
<%= for message <- @messages do %>
<dt><%= message.user %></dt>
<dd><%= message.body %></dd>
<% end %>
</dl>
<%= if @user do %>
<label><%= @user %>:</label>
<form phx-submit="addMessage">
<input type="hidden" name="user" value="<%= @user %>">
<input type="text" name="body" id="message-body-input" data-pending-val="" phx-hook="BodyInput" />
</form>
<% else %>
<label>Name:</label>
<form phx-submit="register">
<input type="text" name="name" />
</form>
<% end %>
"""
end
def handle_event("addMessage", %{"user" => user, "body" => body}, socket) do
message = %Message{
user: user,
body: body,
}
Chat.push_message(message)
socket = assign(socket, :random, "") # socket needs to change for JS Hook to trigger?
{:noreply, socket}
end
def handle_event("register", %{"name" => name}, socket) do
Presence.track(self(), @topic, name, %{ name: name })
socket = assign(socket, :user, name)
{:noreply, socket}
end
def handle_info(%{event: "message", payload: state}, socket) do
socket = assign(socket, state)
{:noreply, socket}
end
def handle_info(%{event: "presence_diff", payload: _payload}, socket) do
users =
Presence.list(@topic)
|> Enum.map(fn {name, _data} ->
name
end)
socket = assign(socket, :users, users)
{:noreply, socket}
end
end
@pootsbook
Copy link
Author

pootsbook commented Oct 20, 2020

At the moment I’m pushing to the queue and then reading everything off the queue.

  • I imagine that later I can just read the initial contents and then rely on updating my “local” list with own messages and messages broadcast from other LiveViews, right?
    Currently passing around the whole queue
  • Is the GenServer just a dumb store? i.e. who should be doing the broadcasting, the LiveView process that has just received a message to all other LiveView processes? Or the GenServer acting as a central server and pushing out the updates it gets from each LiveView process to all? (or all minus LiveView process sending the message?)
    Followed the flow from ElixirSchool which pushes the full queue to other LiveView processes via broadcast_from/4
  • How would I go about architecting so that the DB replaces the GenServer, or would the DB “back” the GenServer as persistent storage and we use the same GenServer as interface/manager?

@tomekowal
Copy link

  • Passing the whole queue is quite clever because everyone sees the same chat history. If you only pass messages one by one, people writing at the same time would see messages in a different order. You can ask the server for last n messages and then merge them by timestamp. But then timestamp must be assigned by the server as a central source of truth.
  • I would personally make the GenServer broadcasting. Make LiveView as dumb as possible. Only keep current state, let the Server handle everything else.
  • DB should "back" the GenServer; E.g. GenServer should work as a smart cache. It should read initial chat history on start. (that part is less trivial, GenServer should not touch DB in init because init must always finish quickly; you have to use {:continue, :initialize} and then def handle_continue(:initialize, state) do

Other things:

  1. You've probably seen how chats show message first as "typed" and later as "sent". It is easy to distinguish here :) When you sent a message with liveview, you can mark it as grey and later your liveview process will receive an update with current messages from GenServer that means it was sent. Emulating "seen by others" would be harder :p
  2. In LiveView def handle_info(%{event: "message" assigns entire state which probably clears current user. That is probably a bug.
    3.In line 84 you match on messages but never use it.
  3. I'd assign timestamp inside GenServer. It serializes the process so we have a single order of events.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment