You've already forked tribes-plugin-aether
forked from tribes/tribes-plugin-template
c1f4339dde
Use TribeOne.TribesPlugin.Aether modules throughout the plugin and expose chat@1 from the entry module for capability-based consumers.
295 lines
9.5 KiB
Elixir
295 lines
9.5 KiB
Elixir
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"""
|
|
<div
|
|
id="aether-chat-panel"
|
|
class={[
|
|
"flex min-h-0 flex-1 flex-col overflow-hidden border border-base-300 bg-base-100 shadow-sm",
|
|
@embedded? && "h-full rounded-2xl",
|
|
!@embedded? && "rounded-3xl"
|
|
]}
|
|
>
|
|
<div
|
|
:if={@embedded?}
|
|
id="chat-embed-header"
|
|
class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3"
|
|
>
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-base-content/50">Chat</p>
|
|
<h2 class="text-base font-semibold text-base-content">{if @channel, do: @channel.title, else: "Chat"}</h2>
|
|
</div>
|
|
<span class="text-xs text-base-content/60">{@message_count}</span>
|
|
</div>
|
|
|
|
<div
|
|
:if={@backend == :marmot}
|
|
id="chat-marmot-notice"
|
|
class="border-b border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-content"
|
|
>
|
|
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
|
|
</div>
|
|
|
|
<div
|
|
id="chat-messages"
|
|
phx-hook="AetherChatScroll"
|
|
class={[
|
|
"flex-1 space-y-3 overflow-y-auto p-4",
|
|
!@embedded? && "min-h-[24rem] sm:p-6",
|
|
@embedded? && "min-h-0"
|
|
]}
|
|
>
|
|
<div
|
|
:if={@messages == []}
|
|
id="chat-empty"
|
|
class="rounded-box border border-dashed border-base-300 p-8 text-center text-sm text-base-content/60"
|
|
>
|
|
No messages yet. Start the conversation.
|
|
</div>
|
|
|
|
<article :for={message <- @messages} id={"chat-message-#{message.id}"} class="aether-chat-message flex gap-3">
|
|
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
|
{author_initial(message)}
|
|
</div>
|
|
<div class="max-w-[min(42rem,85%)] rounded-2xl bg-base-200 px-4 py-3 shadow-sm">
|
|
<div class="mb-1 flex items-center gap-2 text-xs text-base-content/60">
|
|
<span class="font-semibold text-base-content/75">{author_label(message)}</span>
|
|
<span
|
|
phx-hook="DateTime"
|
|
id={"chat-time-#{message.id}"}
|
|
phx-update="ignore"
|
|
data-timestamp={message_timestamp(message)}
|
|
data-relative="true"
|
|
data-format="LLL"
|
|
>
|
|
</span>
|
|
</div>
|
|
<p class="whitespace-pre-wrap break-words text-sm leading-6 text-base-content">{message.body}</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<%= cond do %>
|
|
<% @backend == :marmot -> %>
|
|
<div id="chat-marmot-disabled" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
|
|
Marmot chat is not enabled in the web UI yet.
|
|
</div>
|
|
<% @current_user -> %>
|
|
<form id="chat-message-form" phx-submit="send_message" phx-target={@myself} class="border-t border-base-300 bg-base-100 p-3 sm:p-4">
|
|
<div class="flex items-end gap-2 rounded-2xl border border-base-300 bg-base-200/60 p-2">
|
|
<label for="chat-message-body" class="sr-only">Message</label>
|
|
<textarea
|
|
id="chat-message-body"
|
|
name="message[body]"
|
|
rows="2"
|
|
class="textarea textarea-ghost min-h-12 flex-1 resize-none bg-transparent focus:outline-none"
|
|
placeholder={"Message #{if @channel, do: @channel.title, else: "chat"}"}
|
|
></textarea>
|
|
<button id="chat-send-button" type="submit" class="btn btn-primary rounded-full">
|
|
Send
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<% true -> %>
|
|
<div id="chat-signed-out" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
|
|
Sign in to join the chat.
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
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
|