Disappearing messages - ETS

We are going to add a snapchat-like disappearing messages using OTP built-in Erlang Term Storage (ETS). ETS is a robust in-memory store for Elixir and Erlang objects that comes included. ETS is capable of storing large amounts of data and offers constant time data access. Tables in ETS are created and owned by individual processes. When an owner process terminates, its tables are destroyed. By default, ETS is limited to 1400 tables per node. If you come from a Ruby world you can treat ETS as an equivalent of Redis key-value store.

First of all, we need to create ETS table on application startup. Add private init function to lib/slackir/application.ex

defp init_ets() do
  :ets.new(:disappearing_messages_table, [:set, :public, :named_table])
end

Then call this funtion in start() function callback. This function should look like that:

def start(_type, _args) do
  import Supervisor.Spec

  # Define workers and child supervisors to be supervised
  children = [
    # Start the Ecto repository
    supervisor(Slackir.Repo, []),
    # Start the endpoint when the application starts
    supervisor(SlackirWeb.Endpoint, []),
    # Start your own worker by calling: Slackir.Worker.start_link(arg1, arg2, arg3)
    # worker(Slackir.Worker, [arg1, arg2, arg3]),
  ]

  # See https://hexdocs.pm/elixir/Supervisor.html
  # for other strategies and supported options
  opts = [strategy: :one_for_one, name: Slackir.Supervisor]
  init_ets()
  Supervisor.start_link(children, opts)
end

Now we need to add information in message form about disappearing. Lets add a checkbox in lib/slackir_web/templates/page/index.html.eex

The content of this file should look like that:

<div id='message-list' class='row'>
</div>

<div class='row form-group'>
  <div class='col-md-3'>
    <input type='text' id='name' class='form-control' placeholder='Name' />
  </div>
  <div class='col-md-9'>
    <input type='text' id='message' class='form-control' placeholder='Message' />
    <input type='checkbox' id='disappear' class='form-control'>Disappear
  </div>
</div>

In the next step we need to pass the value of added checkbox to a server through WebSockets. To achieve this lets edit assets/js/socket.js

A new variable let disappear will store a value of the checkbox. Then we need to update action which is responsible for sending a message:

channel.push('shout', { name: name.val(), message: message.val(), disappear: disappear.is(':checked') });

The whole file should look like that:

socket.connect()
let channel   = socket.channel("random:lobby", {});
let list      = $('#message-list');
let message   = $('#message');
let name      = $('#name');
let disappear = $('#disappear');

message.on('keypress', event => {
  if (event.keyCode == 13) {
    channel.push('shout', { name: name.val(), message: message.val(), disappear: disappear.is(':checked') });
    message.val('');
  }
});

channel.on('shout', payload => {
  list.append(`<b>${payload.name || 'Anonymous'}:</b> ${payload.message}<br>`);
  list.prop({scrollTop: list.prop("scrollHeight")});
});

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

channel.on('messages_history', messages => {
  let messages_list = messages["messages"];

  messages_list.forEach( function(msg) {
    list.append(`<b>${msg["name"] || 'Anonymous'}:</b> ${msg["message"]}<br>`);
    list.prop({scrollTop: list.prop("scrollHeight")});
  });
});

export default socket

We can now modify lib/slackir/web/channels/random_channel.ex to handle new data.

In order to handle it with the Elixir-way, we need to change this function def handle_in("shout", payload, socket) do to pattern match when disappear param is false:

def handle_in("shout", %{disappear: false} = payload, socket) do
  spawn(Slackir.Conversations, :create_message, [payload])
  broadcast socket, "shout", payload
  {:noreply, socket}
end

Then we can add next version of this function which will handle %{disappear: true} case, but this time saving it in ETS table.

def handle_in("shout", %{disappear: true} = payload, socket) do
  spawn(:ets, :insert, [:disappearing_messages_table, {NaiveDateTime.utc_now(), payload["name"], payload["message"]}])
  broadcast socket, "shout", payload
  {:noreply, socket}
end

This line should be explained by a mentor spawn(:ets, :insert, [:disappearing_messages_table, {NaiveDateTime.utc_now(), payload["name"], payload["message"]}]). Basically, we are spawning a process which will insert our name and message into disappearing_messages_table in ETS along with a DateTime. Watch out for NaiveDateTime.utc_now(). This is added as a first element in a tuple because each key needs to be unique.

Next step is to retrieve all this disappearing messages in def handle_info(:after_join, socket) do function accordingly:

def handle_info(:after_join, socket) do
  messages =
    Slackir.Conversations.list_messages()
    |> Enum.map(&(%{message: &1.message, name: &1.name, disappear: false, timestamp: &1.inserted_at}))
  messages_ets =
    :ets.match(:disappearing_messages_table, {:"$1", :"$2", :"$3"})
    |> Enum.map(&(%{message: Enum.at(&1, 2), name: Enum.at(&1, 1), disappear: true, timestamp: Enum.at(&1, 0)}))

  messages =
    messages ++ messages_ets
    |> Enum.sort(&(NaiveDateTime.compare(&1.timestamp, &2.timestamp) == :lt))

  push socket, "messages_history", %{messages: messages}
  {:noreply, socket}
end

In this block of code, we fetched the data from ETS table :disappearing_messages_table by using this line :ets.match(:disappearing_messages_table, {:"$1", :"$2", :"$3"}) and then just mapped to corresponding structure. We updated also a message structure with :disappear key to distinguish both types of messages and also :timestamp. Afterward, we concatenate two lists of messages and sort them by timestamp (If necessary this operation can be explained in details by mentors).

The last thing that we need to do is a visual side, just to have the difference between the two types of messages. Let's go again to assets/js/socket.js. We need to update two things.

Firstly, let's change retrieving a messages_history and indicate temporary message with a red color font.

channel.on('messages_history', messages => {
  let messages_list = messages["messages"];

  messages_list.forEach( function(msg) {
    let line_message = "";
    if (msg["disappear"]) {
      line_message = `<b>${msg["name"] || 'Anonymous'}:</b> <font color="red">${msg["message"]}</font><br>`;
    } else {
      line_message = `<b>${msg["name"] || 'Anonymous'}:</b> ${msg["message"]}<br>`;
    }
    list.append(line_message);
    list.prop({scrollTop: list.prop("scrollHeight")});
  });
});

Secondly, the same change with handling shout message.

channel.on('shout', payload => {
  let line_message = "";
  if (payload.disappear) {
    line_message = `<b>${payload.name || 'Anonymous'}:</b> <font color="red">${payload.message}</font><br>`;
  } else {
    line_message = `<b>${payload.name || 'Anonymous'}:</b> ${payload.message}<br>`;
  }
  list.append(line_message);
  list.prop({scrollTop: list.prop("scrollHeight")});
});

This was the final step. Now we can test if it works correctly. Run the app and send both types of messages. Then run the app in a new tab. Both types of messages should be fetched. Now when you rerun your app and refresh the browser you should see only permanent messages.

In the next chapter, we will improve our snapchat-like messages with an expiration time to not have to rerun the app.