From be957afe4432c91067e5e9c8a745837ca20ef692 Mon Sep 17 00:00:00 2001 From: Alain Zscheile Date: Sat, 26 Nov 2022 17:27:01 +0100 Subject: [PATCH] basic chat stuff --- assets/js/app.js | 1 + assets/js/user_socket.js | 85 +++++++++++++++++++ lib/chat_web/channels/room_channel.ex | 32 +++++++ lib/chat_web/channels/user_socket.ex | 41 +++++++++ lib/chat_web/endpoint.ex | 4 + lib/chat_web/templates/layout/root.html.heex | 12 +-- lib/chat_web/templates/page/index.html.heex | 52 +++--------- test/chat_web/channels/room_channel_test.exs | 27 ++++++ .../controllers/page_controller_test.exs | 2 +- test/support/channel_case.ex | 34 ++++++++ 10 files changed, 239 insertions(+), 51 deletions(-) create mode 100644 assets/js/user_socket.js create mode 100644 lib/chat_web/channels/room_channel.ex create mode 100644 lib/chat_web/channels/user_socket.ex create mode 100644 test/chat_web/channels/room_channel_test.exs create mode 100644 test/support/channel_case.ex diff --git a/assets/js/app.js b/assets/js/app.js index 2ca06a5..c143f98 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,7 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" +import socket from "./user_socket.js" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) diff --git a/assets/js/user_socket.js b/assets/js/user_socket.js new file mode 100644 index 0000000..3e0e94b --- /dev/null +++ b/assets/js/user_socket.js @@ -0,0 +1,85 @@ +// NOTE: The contents of this file will only be executed if +// you uncomment its entry in "assets/js/app.js". + +// Bring in Phoenix channels client library: +import {Socket} from "phoenix" + +// And connect to the path in "lib/chat_web/endpoint.ex". We pass the +// token for authentication. Read below how it should be used. +let socket = new Socket("/socket", {params: {token: window.userToken}}) + +// When you connect, you'll often need to authenticate the client. +// For example, imagine you have an authentication plug, `MyAuth`, +// which authenticates the session and assigns a `:current_user`. +// If the current user exists you can assign the user's token in +// the connection for use in the layout. +// +// In your "lib/chat_web/router.ex": +// +// pipeline :browser do +// ... +// plug MyAuth +// plug :put_user_token +// end +// +// defp put_user_token(conn, _) do +// if current_user = conn.assigns[:current_user] do +// token = Phoenix.Token.sign(conn, "user socket", current_user.id) +// assign(conn, :user_token, token) +// else +// conn +// end +// end +// +// Now you need to pass this token to JavaScript. You can do so +// inside a script tag in "lib/chat_web/templates/layout/app.html.heex": +// +// +// +// You will need to verify the user token in the "connect/3" function +// in "lib/chat_web/channels/user_socket.ex": +// +// def connect(%{"token" => token}, socket, _connect_info) do +// # max_age: 1209600 is equivalent to two weeks in seconds +// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do +// {:ok, user_id} -> +// {:ok, assign(socket, :user, user_id)} +// +// {:error, reason} -> +// :error +// end +// end +// +// Finally, connect to the socket: +socket.connect() + +let channel = socket.channel('room:lobby', {}); // connect to chat "room" + +channel.on('shout', function (payload) { // listen to the 'shout' event + let li = document.createElement("li"); // create new list item DOM element + let name = payload.name || 'guest'; // get name from payload or set default + li.innerHTML = '' + name + ': ' + payload.message; // set li contents + ul.appendChild(li); // append to list +}); + +channel.join() + .receive("ok", resp => { console.log("Joined successfully", resp) }) + .receive("error", resp => { console.log("Unable to join", resp) }); + + +let ul = document.getElementById('msg-list'); // list of messages. +let name = document.getElementById('name'); // name of message sender +let msg = document.getElementById('msg'); // message input field + +// "listen" for the [Enter] keypress event to send a message: +msg.addEventListener('keypress', function (event) { + if (event.keyCode == 13 && msg.value.length > 0) { // don't sent empty msg. + channel.push('shout', { // send the message to the server on "shout" channel + name: name.value || "guest", // get value of "name" of person sending the message. Set guest as default + message: msg.value // get message text (value) from msg input field. + }); + msg.value = ''; // reset the message input field for next message. + } +}); + +export default socket diff --git a/lib/chat_web/channels/room_channel.ex b/lib/chat_web/channels/room_channel.ex new file mode 100644 index 0000000..99dfc7b --- /dev/null +++ b/lib/chat_web/channels/room_channel.ex @@ -0,0 +1,32 @@ +defmodule ChatWeb.RoomChannel do + use ChatWeb, :channel + + @impl true + def join("room:lobby", payload, socket) do + if authorized?(payload) do + {:ok, socket} + else + {:error, %{reason: "unauthorized"}} + end + end + + # Channels can be used in a request/response fashion + # by sending replies to requests from the client + @impl true + def handle_in("ping", payload, socket) do + {:reply, {:ok, payload}, socket} + end + + # It is also common to receive messages from the client and + # broadcast to everyone in the current topic (room:lobby). + @impl true + def handle_in("shout", payload, socket) do + broadcast(socket, "shout", payload) + {:noreply, socket} + end + + # Add authorization logic here as required. + defp authorized?(_payload) do + true + end +end diff --git a/lib/chat_web/channels/user_socket.ex b/lib/chat_web/channels/user_socket.ex new file mode 100644 index 0000000..58e9390 --- /dev/null +++ b/lib/chat_web/channels/user_socket.ex @@ -0,0 +1,41 @@ +defmodule ChatWeb.UserSocket do + use Phoenix.Socket + + # A Socket handler + # + # It's possible to control the websocket connection and + # assign values that can be accessed by your channel topics. + + ## Channels + + channel "room:lobby", ChatWeb.RoomChannel + + # Socket params are passed from the client and can + # be used to verify and authenticate a user. After + # verification, you can put default assigns into + # the socket that will be set for all channels, ie + # + # {:ok, assign(socket, :user_id, verified_user_id)} + # + # To deny connection, return `:error`. + # + # See `Phoenix.Token` documentation for examples in + # performing token verification on connect. + @impl true + def connect(_params, socket, _connect_info) do + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # Elixir.ChatWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + @impl true + def id(_socket), do: nil +end diff --git a/lib/chat_web/endpoint.ex b/lib/chat_web/endpoint.ex index b81eaac..2cf1bb1 100644 --- a/lib/chat_web/endpoint.ex +++ b/lib/chat_web/endpoint.ex @@ -12,6 +12,10 @@ defmodule ChatWeb.Endpoint do socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + socket "/socket", ChatWeb.UserSocket, + websocket: true, + longpoll: false + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest diff --git a/lib/chat_web/templates/layout/root.html.heex b/lib/chat_web/templates/layout/root.html.heex index bc9ad36..2956ae5 100644 --- a/lib/chat_web/templates/layout/root.html.heex +++ b/lib/chat_web/templates/layout/root.html.heex @@ -12,17 +12,9 @@
-
<%= @inner_content %> diff --git a/lib/chat_web/templates/page/index.html.heex b/lib/chat_web/templates/page/index.html.heex index f844bd8..5049786 100644 --- a/lib/chat_web/templates/page/index.html.heex +++ b/lib/chat_web/templates/page/index.html.heex @@ -1,41 +1,13 @@ -
-

<%= gettext "Welcome to %{name}!", name: "Phoenix" %>

-

Peace of mind from prototype to production

-
+ + + +
+
+ +
+
+ +
+
-
- - -
diff --git a/test/chat_web/channels/room_channel_test.exs b/test/chat_web/channels/room_channel_test.exs new file mode 100644 index 0000000..1da4eb6 --- /dev/null +++ b/test/chat_web/channels/room_channel_test.exs @@ -0,0 +1,27 @@ +defmodule ChatWeb.RoomChannelTest do + use ChatWeb.ChannelCase + + setup do + {:ok, _, socket} = + ChatWeb.UserSocket + |> socket("user_id", %{some: :assign}) + |> subscribe_and_join(ChatWeb.RoomChannel, "room:lobby") + + %{socket: socket} + end + + test "ping replies with status ok", %{socket: socket} do + ref = push(socket, "ping", %{"hello" => "there"}) + assert_reply ref, :ok, %{"hello" => "there"} + end + + test "shout broadcasts to room:lobby", %{socket: socket} do + push(socket, "shout", %{"hello" => "all"}) + assert_broadcast "shout", %{"hello" => "all"} + end + + test "broadcasts are pushed to the client", %{socket: socket} do + broadcast_from!(socket, "broadcast", %{"some" => "data"}) + assert_push "broadcast", %{"some" => "data"} + end +end diff --git a/test/chat_web/controllers/page_controller_test.exs b/test/chat_web/controllers/page_controller_test.exs index e09890c..c0d5602 100644 --- a/test/chat_web/controllers/page_controller_test.exs +++ b/test/chat_web/controllers/page_controller_test.exs @@ -3,6 +3,6 @@ defmodule ChatWeb.PageControllerTest do test "GET /", %{conn: conn} do conn = get(conn, "/") - assert html_response(conn, 200) =~ "Welcome to Phoenix!" + assert html_response(conn, 200) =~ "Chat Example" end end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 0000000..f23dce0 --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,34 @@ +defmodule ChatWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use ChatWeb.ChannelCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + import Phoenix.ChannelTest + import ChatWeb.ChannelCase + + # The default endpoint for testing + @endpoint ChatWeb.Endpoint + end + end + + setup _tags do + :ok + end +end