feat: add embeddable chat panel

Add compact embedded chat routing and provider helpers so other plugins can attach Aether chat channels by URL without linking against Aether internals.
This commit is contained in:
2026-05-25 05:39:37 +02:00
parent f386cd38f5
commit 421fa01076
3 changed files with 152 additions and 59 deletions
+17
View File
@@ -35,6 +35,23 @@ defmodule Aether.Chat do
def default_channel_slug, do: @default_channel_slug
def standalone_path(%Channel{slug: slug}), do: standalone_path(slug)
def standalone_path(slug) when is_binary(slug), do: "/aether/chat/" <> slug
def embed_path(%Channel{slug: slug}), do: embed_path(slug)
def embed_path(slug) when is_binary(slug), do: "/aether/chat/embed/" <> slug
def ensure_context_channel(provider, type, id, attrs \\ %{}, opts \\ [])
when is_binary(provider) and is_binary(type) and is_binary(id) and is_map(attrs) do
attrs
|> Map.merge(%{
context_provider: provider,
context_type: type,
context_id: id
})
|> ensure_channel(opts)
end
def ash_opts(context \\ nil) do
[
authorize?: false,
+117 -59
View File
@@ -9,7 +9,7 @@ defmodule AetherWeb.ChatLive do
@impl true
def mount(_params, session, socket) do
slug = channel_slug(session)
{mode, slug} = channel_request(session)
case Chat.ensure_channel(%{slug: slug, title: channel_title(slug)}) do
{:ok, %Channel{} = channel} ->
@@ -20,6 +20,7 @@ defmodule AetherWeb.ChatLive do
socket
|> assign(:page_title, channel.title)
|> assign(:channel, channel)
|> assign(:embedded?, mode == :embedded)
|> assign(:message_ids, message_ids)
|> assign(:message_count, MapSet.size(message_ids))
|> stream_configure(:messages, dom_id: &("chat-message-" <> &1.id))
@@ -36,6 +37,7 @@ defmodule AetherWeb.ChatLive do
socket
|> assign(:page_title, "Chat")
|> assign(:channel, nil)
|> assign(:embedded?, mode == :embedded)
|> assign(:message_ids, MapSet.new())
|> assign(:message_count, 0)
|> assign(:chat_error, inspect(reason))
@@ -73,6 +75,20 @@ defmodule AetherWeb.ChatLive do
end
@impl true
def render(%{embedded?: true} = assigns) do
~H"""
<div class="aether aether-chat h-full min-h-[28rem] p-2" id="aether-chat-embed">
<.chat_panel
channel={@channel}
current_user={@current_user}
embedded?={@embedded?}
message_count={@message_count}
streams={@streams}
/>
</div>
"""
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
@@ -96,64 +112,104 @@ defmodule AetherWeb.ChatLive do
</div>
</section>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl border border-base-300 bg-base-100 shadow-sm">
<div id="chat-messages" phx-hook="AetherChatScroll" phx-update="stream" class="min-h-[24rem] flex-1 space-y-3 overflow-y-auto p-4 sm:p-6">
<div
id="chat-empty"
class="hidden only:block 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={{id, message} <- @streams.messages} id={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>
<%= if @current_user do %>
<form id="chat-message-form" phx-submit="send_message" 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>
<% else %>
<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>
<.chat_panel
channel={@channel}
current_user={@current_user}
embedded?={@embedded?}
message_count={@message_count}
streams={@streams}
/>
</div>
</Layouts.app>
"""
end
defp chat_panel(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
id="chat-messages"
phx-hook="AetherChatScroll"
phx-update="stream"
class={[
"flex-1 space-y-3 overflow-y-auto p-4",
!@embedded? && "min-h-[24rem] sm:p-6",
@embedded? && "min-h-0"
]}
>
<div
id="chat-empty"
class="hidden only:block 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={{id, message} <- @streams.messages} id={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>
<%= if @current_user do %>
<form id="chat-message-form" phx-submit="send_message" 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>
<% else %>
<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 post_message(socket, body) do
user = socket.assigns.current_user
@@ -193,15 +249,17 @@ defmodule AetherWeb.ChatLive do
end
end
defp channel_slug(%{"plugin_path" => path}) when is_binary(path) do
defp channel_request(%{"plugin_path" => path}) when is_binary(path) do
case String.split(path, "/", trim: true) do
["aether", "chat", slug | _rest] -> slug
["plugins", "aether", "chat", slug | _rest] -> slug
_other -> Chat.default_channel_slug()
["aether", "chat", "embed", slug | _rest] -> {:embedded, slug}
["plugins", "aether", "chat", "embed", slug | _rest] -> {:embedded, slug}
["aether", "chat", slug | _rest] -> {:standalone, slug}
["plugins", "aether", "chat", slug | _rest] -> {:standalone, slug}
_other -> {:standalone, Chat.default_channel_slug()}
end
end
defp channel_slug(_session), do: Chat.default_channel_slug()
defp channel_request(_session), do: {:standalone, Chat.default_channel_slug()}
defp channel_title("general"), do: "General Chat"
+18
View File
@@ -28,6 +28,15 @@ defmodule Aether.ChatPageTest do
assert has_element?(view, "#chat-messages", "Hello from chat test")
end
test "renders compact embedded chat", %{conn: conn} do
slug = "embed-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/embed/#{slug}")
assert has_element?(view, "#aether-chat-embed")
assert has_element?(view, "#chat-embed-header")
refute has_element?(view, "#aether-chat-page")
end
test "ensures context group channels with stable slugs" do
attrs = %{
context_provider: "sender",
@@ -42,5 +51,14 @@ defmodule Aether.ChatPageTest do
assert first.id == second.id
assert first.slug == "sender-stream-stream-123"
assert first.conversation_kind == :context_group
assert Chat.embed_path(first) == "/aether/chat/embed/sender-stream-stream-123"
end
test "ensures context group channels through provider helper" do
assert {:ok, channel} =
Chat.ensure_context_channel("sender", "stream", "stream-456", %{title: "Stream Chat"})
assert channel.slug == "sender-stream-stream-456"
assert channel.conversation_kind == :context_group
end
end