You've already forked tribes-plugin-aether
forked from tribes/tribes-plugin-template
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user