LiveView Chat
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
- 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 thendef handle_continue(:initialize, state) do
Other things:
- 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
- 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 onmessages
but never use it. - 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
At the moment I’m pushing to the queue and then reading everything off the queue.
Currently passing around the whole queue
Followed the flow from ElixirSchool which pushes the full queue to other LiveView processes via
broadcast_from/4