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
@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