Skip to main content

Command Palette

Search for a command to run...

How to Build an Anonymous Messaging App With Phoenix and React

Updated
19 min read
S

I'm a software engineer with over 5 years of experience. I love building cool and useful stuff. I'm a problem-solver and enjoy finding clever and simple ways to make things work well for users. I also love sharing with people how I built these cool stuff.

Introduction

In this in-depth tutorial, we'll build an anonymous messaging application. We'll make use of React for creating a dynamic user interface and Phoenix to manage the server-side logic.

What is an Anonymous Messaging App?

Anonymous messaging apps grant users a unique platform to receive messages without exposing the sender's identity. These apps provide a space for candid feedback, confessions, or simply fun interactions. Popular examples of such services include Kubool (https://gdpd.xyz/) and NGL (https://ngl.link/). We'll enhance the application by introducing real-time functionality, allowing users receive messages instantly without needing to refresh their pages.

Prerequisites

Before starting, please ensure the following are installed:

Our application will blend two technologies, so basic knowledge of both is required:

  • React: A widely-used JavaScript library for building modular and interactive user interface components.

  • Phoenix: An Elixir-based framework acclaimed for its real-time communication capabilities and efficient handling of large volumes of concurrent connections.

Unlike traditional setups where a frontend interacts with a backend through API endpoints, we'll seamlessly embed our React components within the Phoenix codebase.

App Requirements and Flow

Our anonymous messaging app will have a simple but interactive flow:

  • Home Page: Introduces the app, displaying "register" and "login" buttons.

  • Registration: Users create accounts with a unique email address and password.

  • User Page: Logged-in users see received messages (or a "no messages yet" notice). A shareable link will be displayed to the users that lets others send them anonymous messages. This link can be copied.

  • Message Sending: Anonymous users visiting a shareable link can type messages in a text field and send the message.

  • Real-time Updates: Messages appear instantly for the recipient.

Application Routes

For our application, we’re focused on the following routes:

  • / - Home page

  • /users/register - Registration page

  • /users/log_in - Login page

  • /users/messages - User's message page

  • /send_messages/:recipient_id - Page for sending messages to a specific user

Development Steps

Creating a New Phoenix Project

Assuming we have the prerequisites installed (refer to the Introduction), let's open our terminal and and create a new Phoenix project by running:

mix phx.new anon_messages

When prompted to fetch and install dependencies, type Y and click enter.

When this is done, navigate to anon_messages directory and create the database:

cd anon_messages && mix ecto.create

Finally, we start the application by running:

mix phx.server

Now we can view our application at http://localhost:4000.

Understanding the Routes (router.ex)

In our generated project, the routes live in the lib/anon_messages_web/router.ex file. In this file, we'll see a line similar to this:

get "/", PageController, :home

This line maps the root path (/) to the PageController module and the home action. When a user visits the homepage, the home function in the PageController gets executed, rendering the associated view. We’ll find the PageController in lib/anon_messages_web/controllers/page_controller.ex:

defmodule AnonMessagesWeb.PageController do
  use AnonMessagesWeb, :controller

  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end
end

The PageController renders views located in the lib/anon_messages_web/templates/page_html directory. The home action specifically renders the home.html.heex template. This HEEx file allows us to embed Elixir expressions within our HTML code.

Customizing the Landing Page

The default Phoenix setup provides a basic landing page. We'll adapt this page by modifying the view to introduce our anonymous messaging concept. We'll edit the lib/anon_messages_web/controllers/page_html/home.html.heex file. Let's replace the content of this file with this:

<.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
  <svg
    viewBox="0 0 1480 957"
    fill="none"
    aria-hidden="true"
    class="absolute inset-0 h-full w-full"
    preserveAspectRatio="xMinYMid slice"
  >
    # ...
  </svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-48">
  <div class="mx-auto max-w-xl lg:mx-0">
    <svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
      <path
        d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
        fill="#FD4F00"
      />
    </svg>
    <p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900">
      Spill the Secrets (Anonymously, of Course)
    </p>
    <p class="mt-4 text-base leading-7 text-zinc-600">
      Confessions, crushes, or just random thoughts – share them without revealing who you are.
    </p>
    <p class="mt-4 text-base leading-7 text-zinc-600">
      Need honest opinions, burning questions, or a place to vent without judgment? Share and receive messages with your identity protected.
    </p>

    <.button class="!px-8 mt-16">Get started</.button>
  </div>
</div>

When we refresh the app, the landing page should look like this:

Screenshot 2024-04-03 at 2.15.15 PM.png

Setting up the Authentication Flow

In this section, we’ll add an authentication layer to handle user registration and login securely, and we’ll create the user interface for the login and sign up pages.

Generating an Authentication Layer

To generate an authentication layer, we’ll run

mix phx.gen.auth Accounts User users

This command does a lot of the heavy lifting for us:

  • It creates an Accounts context, which will house our authentication logic.

  • It generates a User schema to model our user data.

  • The pluralized users indicates the name of the corresponding database table.

After running the command we may see the prompt "Do you want to create a LiveView based authentication system?". Type Y and click Enter.

Next we’ll install the dependencies that our generated code requires. Run:

mix deps.get

Running migrations

If we try to start our app at this point, we'd likely encounter an error message about pending migrations.

Screenshot 2024-04-03 at 1.17.59 PM.png

Migrations are like blueprints for our database – they define how our tables should be created and modified. To align our database with our code, we'll run:

mix ecto.migrate

Exploring the Generated Authentication Views

Let's examine the views and routes created by our authentication generator to understand how users will register and log in to our app.

Our lib/anon_messages_web/router.ex file now includes new authentication routes. Let's break down a few key lines:

  • live "/users/register", UserRegistrationLive, :new: This maps the "/users/register" route to the UserRegistrationLive LiveView component, rendering the registration form.

  • live "/users/log_in", UserLoginLive, :new: Similarly, this maps the "/users/log_in" route to the UserLoginLive LiveView, rendering the login form.

Customizing Our Views

We can find the HTML that determines the appearance of our registration and login pages within the render functions of UserRegistrationLive (lib/anon_messages_web/live/user_registration_live.ex) and UserLoginLive (lib/anon_messages_web/live/user_login_live.ex) respectively. We can modify these as needed to align with our design preferences.

Notice also the new “Register” and “Login” links displayed at the top right corner of the homepage and other pages. They were added to our root layout (lib/anon_messages_web/templates/layout/root.html.heex):

<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
  <%= if @current_user do %>
    <li class="text-[0.8125rem] leading-6 text-zinc-900">
      <%= @current_user.email %>
    </li>
    <li>
      <.link
        href={~p"/users/settings"}
        class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
      >
        Settings
      </.link>
    </li>
    <li>
      <.link
        href={~p"/users/log_out"}
        method="delete"
        class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
      >
        Log out
      </.link>
    </li>
  <% else %>
    <li>
      <.link
        href={~p"/users/register"}
        class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
      >
        Register
      </.link>
    </li>
    <li>
      <.link
        href={~p"/users/log_in"}
        class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
      >
        Log in
      </.link>
    </li>
  <% end %>
</ul>

This code dynamically displays different links depending on whether a user is logged in:

  • Logged in: We'll see the user's email, "Settings," and "Log out" links.

  • Not logged in: "Register" and "Log in" links will appear.

Linking the “Get started” Button to the Register Page

We can make the "Get Started" button on our homepage direct users to the registration page by updating the code in lib/anon_messages_web/controllers/page_html/home.html.heex. We’ll wrap the “Get started” button with a link that routes to "/users/register"

<.link
  href={~p"/users/register"}
  class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
  <.button class="!px-8 mt-16">Get started</.button>
</.link>

We now have a fully functional authentication layer for our application.

Let's visit our registration page, create an account, and experiment with logging in and out. We can also explore the provided password reset functionality.

Creating the User’s Messages Page

In this section, we'll build the /users/messages route, where logged-in users can see their received messages and access a unique shareable link. We'll render React components within the LiveView that this route renders.

We’ll begin by adding the following route in lib/anon_messages_web/router.ex within the scope that uses the :require_authenticated_user plug. This ensures that only authenticated users can access this route:

scope "/", AnonMessagesWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :require_authenticated_user,
    on_mount: [{AnonMessagesWeb.UserAuth, :ensure_authenticated}] do
    # ...

    live "/users/messages", UserMessagesLive, :index
  end
end

Next, we’ll create the UserMessagesLive module (lib/anon_messages_web/live/user_messages_live.ex) and render a container for the React component:

defmodule AnonMessagesWeb.UserMessagesLive do
  use AnonMessagesWeb, :live_view

  def render(assigns) do
    ~H"""
    <div phx-update="ignore" phx-hook="UserMessages" id="user-messages"></div>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end

Adding React Components to the Phoenix Application

The real magic happens when we connect React to our LiveView. We'll add a client-side hook in assets/js/app.jsx that renders our React component when the container element is mounted, and unmounts it when removed:

// ...

import UserMessages from "./components/UserMessages";
import mount from "./mount";
import React from "react";

const Hooks = {
  UserMessages: {
    mounted() {
      this.unmount = mount(this.el.id, <UserMessages />);
    },

    destroyed() {
      if (!this.unmount) {
        console.error(`${this.el.id} component is not rendered`);
      } else {
        this.unmount();
      }
    },
  },
};

// ...

let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
});

// ...

We define the UserMessages hook, which is an object with some lifecycle callbacks:

  • mounted is invoked when the LiveView element containing the phx-hook="UserMessages" attribute is added to the DOM (i.e., is rendered on the page). We call the mount function. This function will handle the actual rendering of our <UserMessages/> React component within the designated container and return another function responsible for unmounting the React component. We store this unmount function in this.unmount for later use.

  • destroyedis executed when the LiveView element is removed from the DOM. Here, we use this.unmount, if it exists, to unmount the mounted React component.

We may get a warning that requires us to use the jsx file extension for files that have JSX content. Change assets/js/app.js to assets/js/app.jsx. We also need to inform our Phoenix configuration (config/config.exs) of this update:

config :esbuild,
  version: "0.17.11",
  default: [
    args:
      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

We’ll replace js/app.js with js/app.jsx. After editing the config file, we need to stop the server with CTRL + C and restart the server with mix phx.server.

Next, let us create this mount function in assets/js/mount.jsx:

import React from "react";
import { createRoot } from "react-dom/client";

const mount = (id, component) => {
  const container = document.getElementById(id);

  const root = createRoot(container);
  root.render(<React.StrictMode>{component}</React.StrictMode>);

  return () => {
    root.unmount();
  };
};

export default mount;

This is what this mount function does:

  1. It starts by getting a reference to the DOM element where we want to render the React component. It does this using document.getElementById(id).

  2. The createRoot function from react-dom/client is used to create a React root. A React root is essentially a container within the DOM where React will manage the component tree and updates.

  3. root.render(...) is the key step where the provided React component is rendered inside the React root. The <React.StrictMode> wrapper is optional but helps with potential issue detection during development.

  4. The mount function returns another function. This returned function removes the component and its associated updates from the DOM. This is the function we call in the destroyed callback of our UserMessages hook.

Next we create the <UserMessages /> component at assets/js/components/UserMessages.jsx:

import React from "react";

const UserMessages = () => {
  return <div>UserMessages</div>;
};

export default UserMessages;

Finally, we’ll install the required dependencies for React. From the root directory, navigate to the assets/ directory and install react and react-dom:

cd assets && npm i react react-dom

With these done, when we log in and navigate to the /users/messages route, we should see the rendered <UserMessages /> component.

Creating the User Messages Interface

Let’s update the <UserMessages /> to display the user’s messages and a link to copy. Update the content of assets/js/components/UserMessages.jsx with this:

import React, { useState } from "react";

const UserMessages = ({ userId, messages }) => {
  const shareableLink = `http://localhost:3000/send_messages/${userId}`;

  return (
    <div className="border-solid border border-[#E8E8E8] px-8 py-8 rounded-lg h-full flex flex-col justify-between">
      <div className="h-[calc(100%_-_120px)]">
        <h1 className="text-black text-2xl font-semibold text-center mb-4">
          Messages for you
        </h1>

        <ul className="flex flex-col gap-y-4  overflow-y-auto h-[calc(100%_-_72px)]">
          {messages.map((message, index) => (
            <li
              key={message.content}
              className="bg-[#FAFAFA] border-[#E8E8E8] border border-solid px-6 py-6 rounded-lg"
            >
              <p className="text-base text-black">{message.content}</p>
              <small className="block text-right mt-6">
                {" "}
                - Anonymous [{formatTime(message.inserted_at)}]
              </small>
            </li>
          ))}

          {!messages.length && (
            <li className="h-full font-medium flex items-center justify-center">
              You don't have any messages yet
            </li>
          )}
        </ul>
      </div>

      <div>
        <p className="text-base">
          Share this link to let people send you candid thoughts, questions, or
          suggestions. You won't know who it's from!
        </p>
        <input
          type="text"
          className="border-solid border border-[#E8E8E8] px-4 py-4 rounded-lg w-full mt-2 text-center font-medium"
          value={shareableLink}
          readOnly
        />
      </div>
    </div>
  );
};

export default UserMessages;

const formatTime = (dateTimeString) => {
  if (!dateTimeString) return "";

  const dateTime = new Date(dateTimeString);
  return dateTime.toLocaleDateString("en-US", {
    day: "2-digit",
    month: "short",
    year: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
};

We make the <UserMessages /> component accept a userId prop which we use to form the user’s shareable link. We’ll fetch this user ID later from the LiveView. Then loop through the messages list and display each of them alongside the time the message was received. We use the formatTime function at the end of this file to format this time. Finally, we display the link the user can share to receive messages.

Fetching the Authenticated User

Thanks to our authentication system, accessing the logged-in user is easy. Recall that in lib/anon_messages_web/router.ex, we place our /users/messages route within a live session:

scope "/", AnonMessagesWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :require_authenticated_user,
    on_mount: [{AnonMessagesWeb.UserAuth, :ensure_authenticated}] do
    # ...

    live "/users/messages", UserMessagesLive, :index
  end
end

Any route that is placed within this live session invokes the on_mount function defined in AnonMessagesWeb.UserAuth module whose first argument matches :ensure_authenticated. We can see that in lib/anon_messages_web/user_auth.ex:

def on_mount(:ensure_authenticated, _params, session, socket) do
  socket = mount_current_user(socket, session)

  if socket.assigns.current_user do
    {:cont, socket}
  else
    socket =
      socket
      |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
      |> Phoenix.LiveView.redirect(to: ~p"/users/log_in")

    {:halt, socket}
  end
end

This function calls mount_current_user and assigns the result to socket.assigns. Then it checks if socket.assigns.current_user is truthy. If truthy, the connection continues. Otherwise, the connection halts, displaying an error message and redirecting to the login page. Let's examine the contents of the mount_current_user function (also in the same file).

defp mount_current_user(socket, session) do
  Phoenix.Component.assign_new(socket, :current_user, fn ->
    if user_token = session["user_token"] do
      Accounts.get_user_by_session_token(user_token)
    end
  end)
end

This function gets the user and Phoenix.Component.assign_new creates a new :current_user key in socket.assigns and assigns the current user to it.

Passing the User ID to the Client

In lib/anon_messages_web/live/user_messages_live.ex, we’ll pass the user ID to the container element as a value for the data-userid attribute:

defmodule AnonMessagesWeb.UserMessagesLive do
  use AnonMessagesWeb, :live_view

  def render(assigns) do
    ~H"""
    <div
      phx-update="ignore"
      phx-hook="UserMessages"
      id="user-messages"
      class="h-full"
      data-userid={assigns.current_user.id}
    >
    </div>
    """
  end

  # ...
end

Now we can retrieve the user ID in assets/js/app.jsx and pass it to the React component:

const Hooks = {
  UserMessages: {
    mounted() {
      this.unmount = mount(
        this.el.id,
        <UserMessages userId={this.el?.dataset?.userid} messages={[]} />
      );
    },

    // ...
  },
};

We get the value of the data-userid attribute from this.el?.dataset?.userid and pass it to the userId prop that the <UserMessages /> component accepts.

With these done, we can refresh our browser and our app should be looking almost done:

Screenshot 2024-04-04 at 6.20.39 PM.png

<aside> 💡 You can style any of these pages as you like. You may notice that the Tailwind classes applied to this <UserMessages /> are not being reflected. If that’s the case, check the tailwind config (assets/tailwind.config.js) and update it to also search through jsx files for utility classes. Update the content key as so:

content: ["./js/**/*.{js,jsx}", "../lib/*_web.ex", "../lib/*_web/**/*.*ex"]

</aside>

What’s left is for our app is sending anonymous messages and the user receiving those messages

Sending Anonymous Messages

Creating the Message Sending Page

In this section, we'll build the page where anyone can send anonymous messages without having to log in.

We'll start by adding a new route, /send_messages/:recipient_id in lib/anon_messages_web/router.ex. Since we want the page to be accessible without logging in, we’ll not add this route to the scope or live session that requires the user to be authenticated. We’ll create a new scope in lib/anon_messages_web/router.ex and a live session we’ll call ensure_recipient_exists:

scope "/", AnonMessagesWeb do
  pipe_through [:browser]

  live_session :ensure_recipient_exists,
    on_mount: [{AnonMessagesWeb.UserAuth, :ensure_recipient_exists}] do
    live "/send_messages/:recipient_id", SendMessagesLive, :new
  end
end

We’ll create the on_mount callback that matches ensure_recipient_exists in AnonMessagesWeb.UserAuth (lib/anon_messages_web/user_auth.ex):

def on_mount(:ensure_recipient_exists, params, _session, socket) do
  recipient_id = Map.get(params, "recipient_id")
  socket = mount_recipient(socket, recipient_id)

  if socket.assigns.recipient do
    {:cont, socket}
  else
    socket =
      socket
      |> Phoenix.LiveView.put_flash(
        :error,
        "Oops!, looks like the link is broken. Please check the link and try again."
      )
      |> Phoenix.LiveView.redirect(to: ~p"/")

    {:halt, socket}
  end
end

defp mount_recipient(socket, recipient_id) do
  try do
    recipient = Accounts.get_user!(recipient_id)

    Phoenix.Component.assign_new(socket, :recipient, fn ->
      recipient
    end)
  rescue
    _ ->
      Phoenix.Component.assign_new(socket, :recipient, fn ->
        nil
      end)
  end
end

Here, we get the user_id from the route parameters. Then we call mount_user_by_id, which retrieves the user with that ID and assigns it to socket.assigns.user_by_id. If a valid user is found, the connection continues. Otherwise, we halt the connection, display an error message to the user, and redirect them to the homepage. Any route defined within this ensure_recipient_exists live session will automatically trigger this validation function.

Next, we'll create the SendMessagesLive module, which handles the /send_messages/:recipient_id route. Create the file at lib/anon_messages_web/live/send_messages_live.ex:

defmodule AnonMessagesWeb.SendMessagesLive do
  use AnonMessagesWeb, :live_view

  def render(assigns) do
    ~H"""
    <div
      id="send-messages"
      phx-hook="SendMessages"
      phx-update="ignore"
      data-recipientemail={assigns.recipient.email}
      data-recipientid={assigns.recipient.id}
    >
    </div>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end

Here, we pass the user’s email and id as attributes to the SendMessages container which we’ll retrieve in the hook and pass to the <SendMessages /> component we’ll create

Create the SendMessages hook in assets/js/app.jsx:

import SendMessages from "./components/SendMessages";

// ...

const Hooks = {
  // ...

  SendMessages: {
    mounted() {
      this.unmount = mount(
        this.el.id,
        <SendMessages
          recipientEmail={this.el?.dataset?.recipientemail}
          recipientId={this.el?.dataset?.recipientid}
        />
      );
    },

    destroyed() {
      if (!this.unmount) {
        console.error(`${this.el.id} component is not rendered`);
      } else {
        this.unmount();
      }
    },
  },
};

Finally, we’ll create the <SendMessages /> component at assets/js/components/SendMessages.jsx:

import React, { useState } from "react";

const SendMessages = ({ recipientEmail, recipientId }) => {
  const [message, setMessage] = useState("");

  const handleSubmit = (event) => {
    event.preventDefault();
    setMessage("")
    console.log("Message submitted:", message);
  };

  const handleMessageInputChange = (event) => {
    setMessage(event.target.value);
  };

  return (
    <form
      className="border-solid border border-[#E8E8E8] px-8 py-8 rounded-lg h-full flex flex-col justify-between"
      onSubmit={handleSubmit}
    >
      <h1 className="text-black text-2xl font-semibold text-center mb-4">
        Got something to say? Don't be shy!
      </h1>

      <p className="text-base mt-3 mb-14">
        Send {recipientEmail} a quick, completely anonymous message. It could be
        a compliment, a question, or even a friendly dare.
      </p>

      <textarea
        className="block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem] "
        onChange={handleMessageInputChange}
        value={message}
      ></textarea>

      <button className="rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3 text-sm font-semibold leading-6 text-white active:text-white/80 mt-5">
        Send message
      </button>
    </form>
  );
};

export default SendMessages;

Here, we display a form with a text area and a button.

Generating the Messaging Context

To store the anonymous messages and ensure the recipient can see them upon login, we need a schema to model individual messages. We’ll call the schema Message. Then we need a context module that’ll contain functions that will handle message-related operations. We’ll call the context Messaging.

Phoenix provides a generator we can easily use to generate the context. In the root folder of the project, run the following command:

mix phx.gen.context Messaging Message messages content:string recipient_id:integer

Let’s quickly break down the arguments:

  • Messaging is the name of the context module.

  • Message is the name of the schema module.

  • messages is the pluralized table name in the database.

  • content:string: A field to store the message text.

  • recipient_id:integer: A field to store the user ID of its intended recipient

The generator creates several files, including:

  • lib/anon_messages/messaging/message.ex: This contains the Message schema and associated functions.

  • lib/anon_messages/messaging.ex: This is the Messaging context module containing functions to perform operations on messages.

Finally, we’ll run migrations to inform our database of this new schema. Run:

mix ecto.migrate

Saving Anonymous Messages

Now that we have our context, let's enable saving messages when the send form is submitted. In lib/anon_messages_web/live/send_messages_live.ex, add a handle_event function:

def handle_event("send_message", message_params, socket) do
  case Messaging.create_message(message_params) do
    {:ok, _message} ->
      {:noreply, socket |> put_flash(:info, "Message sent!")}

    {:error, _changeset} ->
      {:noreply, socket |> put_flash(:error, "Oops, something went wrong!")}
  end
end

This calls the create_message from the Messaging context to create the message and provides feedback to the user.

Next, we’ll push this "send_message" event from our form to the LiveView when it is submitted. We’ll start by passing a handleSendMessage function to the <SendMessages /> component:

<SendMessages
  // ...
  handleSendMessage={(content, recipientId) => {
    this.pushEvent("send_message", {
      content,
      recipient_id: Number(recipientId),
    });
  }}
/>;

This function uses the pushEvent method to push the "send_message" event to the containing LiveView, alongsie the message parameters as payload.

Finally, adjust the <SendMessages /> component to trigger this event on form submission:

import React, { useState } from "react";

const SendMessages = ({ recipientEmail, recipientId, handleSendMessage }) => {
  const [message, setMessage] = useState("");

  const handleSubmit = (event) => {
    event.preventDefault();
    handleSendMessage(message, recipientId);
  };

  // ...
};

export default SendMessages;

Displaying User’s Messages

To display a user's received messages, we begin by creating a function in the Messaging context (lib/anon_messages/messaging.ex) to retrieve messages by recipient_id:

# get messages by recipient_id in descending order of inserted_at
def get_messages_by_recipient_id(recipient_id) do
  from(m in Message, where: m.recipient_id == ^recipient_id, order_by: [desc: m.inserted_at])
  |> Repo.all()
end

This query fetches a user's messages and sorts them with the newest messages displayed first.

Next, we'll update lib/anon_messages_web/live/user_messages_live.ex. Add a handle_event callback to respond to the "get_messages" event:

defmodule AnonMessagesWeb.UserMessagesLive do
  alias AnonMessages.Messaging

  # ...

  defp struct_to_map(struct) do
    Map.from_struct(struct) |> Map.delete(:__meta__)
  end

  def handle_event("get_messages", _, socket) do
    user_id = socket.assigns.current_user.id
    messages = Messaging.get_messages_by_recipient_id(user_id)

    # form list of maps from list of structs.
    messages = Enum.map(messages, &struct_to_map/1)

    {:reply, %{"messages" => messages}, socket}
  end

  # ...
end

This fetches messages using our new context function and prepares them in a format suitable for the React component.

This fetches the messages sent to the logged in user using our new context function , and since they are a list of structs, we convert them to a list of maps that React will understand.

Finally, modify the UserMessages Hook in assets/js/app.jsx:

const Hooks = {
  UserMessages: {
    mounted() {
      let messages = [];

      this.pushEvent("get_messages", {}, (response) => {
        messages = response.messages;

        this.unmount = mount(
          this.el.id,
          <UserMessages userId={this.el?.dataset?.userid} messages={messages} />
        );
      });
    },

    // ...
  },

  // ...
};

This triggers the message retrieval when the component mounts and passes the fetched messages to the <UserMessages/> React component.

With these changes, when a user logs in and accesses their messages, they should see the anonymous messages they've received!

For the complete codebase of this project, please refer to the GitHub repository: https://github.com/Steph-crown/anon_messages.

Conclusion

In this tutorial, we've built a solid foundation for an anonymous messaging application within a Phoenix LiveView project. Here's a quick recap of what we've accomplished:

  • Authentication: Set up a secure user authentication system.

  • Message Routing: Created routes for sending and receiving messages.

  • Context and Schema: Implemented a Messaging context and Message schema to work with message data.

  • User Interface: Built React components to display messages and handle sending new ones.

Here are a few ideas for taking this project further:

  • Read Indicators: Add a way to mark messages as "read".

  • Custom Styling: Make the message components more visually appealing.

  • Advanced Features: Consider options like message deletion, replies, or even image attachments.

Adding Real-time updates

Creating the Socket and Channel

Joining the Channel

Pushing a message Updates

Listening for message Updates

Conclusion

you can make it more interesting by useing username instead of user id.

other features they can add to the anonymous chat.