defmodule TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent do @moduledoc """ Reusable chat panel component for standalone and embedded Aether chat surfaces. """ use Phoenix.LiveComponent alias TribeOne.TribesPlugin.Aether.Chat alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message} @impl true def update(%{incoming_message: %Message{} = message}, socket) do {:ok, put_message(socket, message)} end def update(assigns, socket) do slug = Map.fetch!(assigns, :slug) backend = Map.get(assigns, :backend, :public_sync) socket = socket |> assign(assigns) |> assign_new(:channel, fn -> nil end) |> assign_new(:messages, fn -> [] end) |> assign_new(:message_ids, fn -> MapSet.new() end) |> assign_new(:message_count, fn -> 0 end) |> assign_new(:subscribed_channel_id, fn -> nil end) socket = if reload_channel?(socket, slug, backend) do load_channel(socket, slug, backend) else socket end {:ok, maybe_subscribe(socket)} end @impl true def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do body = String.trim(body) cond do socket.assigns.channel == nil -> {:noreply, put_flash(socket, :error, "Chat channel is unavailable")} socket.assigns.current_user == nil -> {:noreply, put_flash(socket, :error, "Sign in to chat")} body == "" -> {:noreply, put_flash(socket, :error, "Message cannot be empty")} true -> post_message(socket, body) end end @impl true def render(assigns) do ~H"""

Chat

{if @channel, do: @channel.title, else: "Chat"}

{@message_count}
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
No messages yet. Start the conversation.
{author_initial(message)}
{author_label(message)}

{message.body}

<%= cond do %> <% @backend == :marmot -> %>
Marmot chat is not enabled in the web UI yet.
<% @current_user -> %>
<% true -> %>
Sign in to join the chat.
<% end %>
""" end defp reload_channel?(socket, slug, backend) do channel = socket.assigns.channel channel == nil || channel.slug != slug || channel.backend != backend end defp load_channel(socket, slug, backend) do case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do {:ok, %Channel{} = channel} -> messages = load_messages(channel, socket.assigns) message_ids = messages |> Enum.map(& &1.id) |> MapSet.new() socket |> assign(:channel, channel) |> assign(:backend, channel.backend) |> assign(:messages, messages) |> assign(:message_ids, message_ids) |> assign(:message_count, MapSet.size(message_ids)) {:error, reason} -> socket |> assign(:channel, nil) |> assign(:backend, backend) |> assign(:messages, []) |> assign(:message_ids, MapSet.new()) |> assign(:message_count, 0) |> assign(:chat_error, inspect(reason)) end end defp maybe_subscribe( %{assigns: %{channel: %Channel{id: id}, subscribed_channel_id: id}} = socket ) do socket end defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do if connected?(socket) do _ = Chat.subscribe_conversation(channel, self(), chat_opts(socket.assigns)) assign(socket, :subscribed_channel_id, channel.id) else socket end end defp maybe_subscribe(socket), do: socket defp post_message(socket, body) do user = socket.assigns.current_user attrs = %{ body: body, author_id: user_attr(user, :id), author_pubkey: user_attr(user, :pubkey_hex) || user_attr(user, :pubkey), metadata: %{"author_name" => user_attr(user, :username)} } case Chat.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) do {:ok, message} -> {:noreply, put_message(socket, message)} {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to send message: #{inspect(reason)}")} end end defp put_message(socket, %Message{} = message) do if socket.assigns.channel && message.channel_id == socket.assigns.channel.id do append_message(socket, message) else socket end end defp append_message(socket, %Message{} = message) do if MapSet.member?(socket.assigns.message_ids, message.id) do socket else message_ids = MapSet.put(socket.assigns.message_ids, message.id) socket |> assign(:messages, socket.assigns.messages ++ [message]) |> assign(:message_ids, message_ids) |> assign(:message_count, MapSet.size(message_ids)) end end defp load_messages(%Channel{} = channel, assigns) do case Chat.list_conversation_messages(channel, chat_opts(assigns)) do {:ok, messages} -> messages {:error, _reason} -> [] end end defp chat_opts(assigns) do case Map.get(assigns, :session_privkey) do privkey when is_binary(privkey) -> [session_privkey: privkey] _other -> [] end end defp channel_title("general"), do: "General Chat" defp channel_title(slug) do slug |> String.replace(["-", "_"], " ") |> String.split(" ", trim: true) |> Enum.map_join(" ", &String.capitalize/1) end defp user_attr(nil, _key), do: nil defp user_attr(user, key), do: Map.get(user, key) defp author_label(%Message{metadata: %{"author_name" => name}}) when is_binary(name) and name != "" do name end defp author_label(%Message{author_pubkey: pubkey}) when is_binary(pubkey) and pubkey != "" do String.slice(pubkey, 0, 8) <> "…" end defp author_label(_message), do: "Unknown" defp author_initial(message) do message |> author_label() |> String.first() |> Kernel.||("?") |> String.upcase() end defp message_timestamp(%Message{inserted_at: %DateTime{} = inserted_at}) do DateTime.to_unix(inserted_at, :millisecond) end defp message_timestamp(_message), do: DateTime.utc_now() |> DateTime.to_unix(:millisecond) end