get the pieces all in order
Bobby Grayson 4 days ago 4 files (+193, -32)
@@ -0,0 +1,120 @@+defmodule Blog.Chat.MessageStore do+ @moduledoc """+ Manages ETS storage for chat messages.++ This module provides a way to persist chat messages between page refreshes+ using ETS (Erlang Term Storage). It handles creation, retrieval and storage+ of messages with efficient retrieval by timestamp.+ """++ @table_name :allowed_chat_messages+ @allowed_words_table :allowed_chat_words+ @max_messages 100+ @topic "allowed_chat"++ @doc """+ Initializes the ETS tables needed for chat functionality.++ Should be called when the application starts.+ """+ def init do+ # Create ETS table for messages if it doesn't exist+ if :ets.info(@table_name) == :undefined do+ :ets.new(@table_name, [:named_table, :ordered_set, :public])+ end++ # Create ETS table for allowed words if it doesn't exist+ if :ets.info(@allowed_words_table) == :undefined do+ :ets.new(@allowed_words_table, [:named_table, :set, :public])+ end++ :ok+ end++ @doc """+ Stores a message in the ETS table.++ The message is stored with a key that ensures messages are ordered by timestamp+ in descending order (most recent first). Message visibility is calculated dynamically+ by the LiveView and not stored in the ETS table.+ """+ def store_message(message) do+ # We use negative timestamp as key to get descending order+ timestamp_key = -DateTime.to_unix(message.timestamp, :microsecond)++ # Store the message (we're removing visibility fields if they exist since they'll be calculated dynamically)+ clean_message = Map.drop(message, [:is_visible, :matching_words])+ :ets.insert(@table_name, {timestamp_key, clean_message})++ # Prune old messages if we exceed the maximum+ prune_messages()++ # Broadcast the new message to all connected clients+ Phoenix.PubSub.broadcast(Blog.PubSub, @topic, {:new_message, clean_message})++ :ok+ end++ @doc """+ Retrieves the most recent messages from the ETS table.++ Returns a list of messages, sorted by timestamp (newest first).+ """+ def get_recent_messages(limit \\ @max_messages) do+ # Get messages ordered by timestamp (newest first)+ case :ets.info(@table_name) do+ :undefined -> []+ _ ->+ :ets.tab2list(@table_name)+ |> Enum.sort() # Sort by key (negative timestamp)+ |> Enum.take(limit)+ |> Enum.map(fn {_key, message} -> message end)+ end+ end++ @doc """+ Stores a user's allowed words in the ETS table.+ """+ def store_allowed_words(user_id, allowed_words) do+ :ets.insert(@allowed_words_table, {user_id, allowed_words})++ # Broadcast allowed words update+ Phoenix.PubSub.broadcast(Blog.PubSub, @topic, {:allowed_words_updated, user_id})++ :ok+ end++ @doc """+ Retrieves a user's allowed words from the ETS table.++ Returns a MapSet of allowed words. If no words are found for the user,+ returns an empty MapSet.+ """+ def get_allowed_words(user_id) do+ case :ets.lookup(@allowed_words_table, user_id) do+ [{^user_id, allowed_words}] -> allowed_words+ [] -> MapSet.new()+ end+ end++ # Private helper to prune old messages+ defp prune_messages do+ case :ets.info(@table_name) do+ :undefined -> :ok+ info ->+ count = info[:size]+ if count > @max_messages do+ # Get all keys+ keys = :ets.tab2list(@table_name) |> Enum.map(fn {k, _} -> k end) |> Enum.sort(:desc)+ # Keep only the most recent @max_messages+ keys_to_delete = Enum.drop(keys, @max_messages)+ Enum.each(keys_to_delete, fn key -> :ets.delete(@table_name, key) end)+ end+ end+ end++ @doc """+ Returns the topic name for PubSub subscriptions.+ """+ def topic, do: @topic+end
@@ -32,7 +32,7 @@ %{property: "og:type", content: "website"}])|> assign(:user_id, user_id)|> assign(:allowed_words, allowed_words)- |> assign(:messages, messages)+ |> assign(:messages, calculate_message_visibility(messages, allowed_words))|> assign(:add_word_form, to_form(%{"word" => ""}))|> assign(:message_form, to_form(%{"content" => ""}))}end@@ -40,15 +40,20 @@@impl truedef handle_event("add_word", %{"word" => word}, socket) when is_binary(word) and word != "" do# Add the word to the allowed_words set- new_allowed_words = MapSet.put(socket.assigns.allowed_words, String.downcase(String.trim(word)))+ word = String.downcase(String.trim(word))+ new_allowed_words = MapSet.put(socket.assigns.allowed_words, word)# Store the updated allowed words in ETSMessageStore.store_allowed_words(socket.assigns.user_id, new_allowed_words)- # Update the socket with the new allowed_words+ # Recalculate message visibility with the new allowed words+ messages = calculate_message_visibility(socket.assigns.messages, new_allowed_words)++ # Update the socket with the new allowed_words and messages{:noreply,socket|> assign(:allowed_words, new_allowed_words)+ |> assign(:messages, messages)|> assign(:add_word_form, to_form(%{"word" => ""}))}end@@ -60,23 +65,27 @@# Store the updated allowed words in ETSMessageStore.store_allowed_words(socket.assigns.user_id, new_allowed_words)- # Update the socket with the new allowed_words- {:noreply, assign(socket, :allowed_words, new_allowed_words)}+ # Recalculate message visibility with the updated allowed words+ messages = calculate_message_visibility(socket.assigns.messages, new_allowed_words)++ # Update the socket with the new allowed_words and messages+ {:noreply,+ socket+ |> assign(:allowed_words, new_allowed_words)+ |> assign(:messages, messages)}end@impl truedef handle_event("send_message", %{"content" => content}, socket) when is_binary(content) and content != "" do- # Check message visibility and get matching allowed words- {is_visible, matching_words} = message_visible_with_words(content, socket.assigns.allowed_words)+ user_id = socket.assigns.user_id+ allowed_words = socket.assigns.allowed_words- # Create a new message map+ # Create a new message map (without is_visible - we'll calculate it dynamically)new_message = %{id: System.unique_integer([:positive]),content: content,timestamp: DateTime.utc_now(),- user_id: socket.assigns.user_id,- is_visible: is_visible,- matching_words: matching_words+ user_id: user_id}# Store the message in ETS@@ -85,10 +94,13 @@# Get updated message listmessages = MessageStore.get_recent_messages()- # Add the new message to the list of messages+ # Calculate visibility for all messages based on current allowed words+ messages_with_visibility = calculate_message_visibility(messages, allowed_words)++ # Update the socket with the messages and reset the message form{:noreply,socket- |> assign(:messages, messages)+ |> assign(:messages, messages_with_visibility)|> assign(:message_form, to_form(%{"content" => ""}))}end@@ -106,7 +118,11 @@ @impl truedef handle_info({:new_message, _message}, socket) do# When a new message is broadcast, update the messages listmessages = MessageStore.get_recent_messages()- {:noreply, assign(socket, :messages, messages)}++ # Calculate visibility for each message based on the current user's allowed words+ messages_with_visibility = calculate_message_visibility(messages, socket.assigns.allowed_words)++ {:noreply, assign(socket, :messages, messages_with_visibility)}end@impl true@@ -114,10 +130,30 @@ def handle_info({:allowed_words_updated, user_id}, socket) do# Only update if it's the current user's allowed words that changedif user_id == socket.assigns.user_id doallowed_words = MessageStore.get_allowed_words(user_id)- {:noreply, assign(socket, :allowed_words, allowed_words)}++ # Recalculate message visibility with the updated allowed words+ messages = calculate_message_visibility(socket.assigns.messages, allowed_words)++ {:noreply,+ socket+ |> assign(:allowed_words, allowed_words)+ |> assign(:messages, messages)}else{:noreply, socket}end+ end++ # Helper function to calculate visibility for a list of messages+ defp calculate_message_visibility(messages, allowed_words) do+ Enum.map(messages, fn message ->+ {is_visible, matching_words} = message_visible_with_words(message.content, allowed_words)++ # Create a new message map with visibility information+ Map.merge(message, %{+ is_visible: is_visible,+ matching_words: matching_words+ })+ end)end# Enhanced helper function to check if a message is visible based on the allowed words@@ -231,29 +267,14 @@ <div class="flex justify-between items-start"><div class="flex-1"><%= if message.is_visible do %><p class="text-gray-800"><%= message.content %></p>- <%= if message[:matching_words] && length(message.matching_words) > 0 do %>- <p class="text-xs text-green-600 mt-1">- Allowed by:- <%= for {word, i} <- Enum.with_index(message.matching_words) do %>- <span class="font-semibold"><%= word %></span><%= if i < length(message.matching_words) - 1, do: ", " %>- <% end %>- </p>- <% end %><% else %><p class="text-gray-400 italic">This message is hidden (no allowed words found)</p><% end %>- <p class="text-xs text-gray-500 mt-1">- <%= Calendar.strftime(message.timestamp, "%B %d, %Y at %I:%M %p") %>- <%= if Map.get(message, :user_id) == @user_id do %>- <span class="ml-2 text-blue-500">(You)</span>- <% end %>- </p></div><span class={["text-xs px-2 py-1 rounded-full",if(message.is_visible, do: "bg-green-200 text-green-800", else: "bg-red-200 text-red-800")]}>- <%= if message.is_visible, do: "Visible", else: "Hidden" %></span></div></div>
@@ -0,0 +1,22 @@+defmodule BlogWeb.Plugs.EnsureUserId do+ @moduledoc """+ A plug that ensures a user_id is present in the session.++ This is used to identify users in the chat functionality.+ """++ import Plug.Conn++ def init(opts), do: opts++ def call(conn, _opts) do+ if get_session(conn, "user_id") do+ # User ID already exists in session+ conn+ else+ # Generate a new user ID and put it in the session+ user_id = System.unique_integer([:positive]) |> to_string()+ put_session(conn, "user_id", user_id)+ end+ end+end
MODIFIED
lib/blog_web/router.ex
MODIFIED
lib/blog_web/router.ex
@@ -28,8 +28,6 @@ live "/mirror", MirrorLive, :indexlive "/reddit-links", RedditLinksLive, :indexlive "/cursor-tracker", CursorTrackerLive, :indexlive "/emoji-skeets", EmojiSkeetsLive, :index- live "/element-craft", ElementCraftLive, :index- live "/messages-from-space", SpaceMessagesLive, :indexlive "/allowed-chats", AllowedChatsLive, :indexend