From 8b1990ee25997ff7907879805831598448c84df7 Mon Sep 17 00:00:00 2001
From: Steffen Beyer
Date: Mon, 25 May 2026 15:54:58 +0200
Subject: [PATCH] feat: add chat backend contract and recipient picker
Introduce plain-Elixir chat backends, participant projections, a chat@1 recipient picker component, and a public-sync user-to-user message flow test while scaffolding NIP-17/NIP-04/Marmot backend capabilities.
---
lib/aether/chat.ex | 116 +++++++++-
lib/aether/chat/backend.ex | 25 +++
lib/aether/chat/backends/marmot.ex | 41 ++++
.../chat/backends/nostr_nip04_read_only.ex | 36 +++
lib/aether/chat/backends/nostr_nip17.ex | 37 ++++
lib/aether/chat/backends/public_sync.ex | 35 +++
lib/aether/chat/channel.ex | 9 +-
lib/aether/chat/participant.ex | 127 +++++++++++
lib/aether_web/live/chat_live.ex | 39 +++-
lib/aether_web/live/chat_panel_component.ex | 8 +-
.../live/chat_recipient_picker_component.ex | 139 ++++++++++++
.../20260525134853_add_chat_participants.exs | 66 ++++++
.../20260525134854.json | 209 ++++++++++++++++++
test/aether/chat_backend_test.exs | 74 +++++++
14 files changed, 950 insertions(+), 11 deletions(-)
create mode 100644 lib/aether/chat/backend.ex
create mode 100644 lib/aether/chat/backends/marmot.ex
create mode 100644 lib/aether/chat/backends/nostr_nip04_read_only.ex
create mode 100644 lib/aether/chat/backends/nostr_nip17.ex
create mode 100644 lib/aether/chat/backends/public_sync.ex
create mode 100644 lib/aether/chat/participant.ex
create mode 100644 lib/aether_web/live/chat_recipient_picker_component.ex
create mode 100644 priv/repo/migrations/20260525134853_add_chat_participants.exs
create mode 100644 priv/repo/resource_snapshots/repo/aether_chat_participants/20260525134854.json
create mode 100644 test/aether/chat_backend_test.exs
diff --git a/lib/aether/chat.ex b/lib/aether/chat.ex
index 6aa87dd..143aa3d 100644
--- a/lib/aether/chat.ex
+++ b/lib/aether/chat.ex
@@ -8,7 +8,7 @@ defmodule Aether.Chat do
require Ash.Query
- alias Aether.Chat.{Channel, Message}
+ alias Aether.Chat.{Channel, Message, Participant}
@default_channel_slug "general"
@default_channel_title "General Chat"
@@ -27,11 +27,33 @@ defmodule Aether.Chat do
define(:get_message, action: :by_id, args: [:id])
define(:list_all_messages, action: :read)
end
+
+ resource Participant do
+ define(:create_participant, action: :create)
+ define(:get_participant, action: :by_channel_pubkey, args: [:channel_id, :pubkey])
+ define(:list_participants, action: :read)
+ define(:list_participants_by_pubkey, action: :by_pubkey, args: [:pubkey])
+ end
end
- def supported_conversation_kinds, do: [:group, :context_group]
+ def backend_module(:public_sync), do: Aether.Chat.Backends.PublicSync
+ def backend_module(:nostr_nip17), do: Aether.Chat.Backends.NostrNip17
+ def backend_module(:nostr_nip04), do: Aether.Chat.Backends.NostrNip04ReadOnly
+ def backend_module(:marmot), do: Aether.Chat.Backends.Marmot
+ def backend_module(_backend), do: Aether.Chat.Backends.PublicSync
- def supported_backends, do: [:public_sync, :marmot]
+ def backend_capabilities(backend) do
+ module = backend_module(backend)
+ module.capabilities()
+ end
+
+ def supported_conversation_kinds, do: [:group, :context_group, :dm, :legacy_dm, :marmot_group]
+
+ def supported_backends, do: [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
+
+ def chat_panel_component, do: AetherWeb.ChatPanelComponent
+
+ def recipient_picker_component, do: AetherWeb.ChatRecipientPickerComponent
def default_channel_slug, do: @default_channel_slug
@@ -52,7 +74,37 @@ defmodule Aether.Chat do
context_type: type,
context_id: id
})
- |> ensure_channel(opts)
+ |> ensure_conversation(opts)
+ end
+
+ def ensure_direct_conversation(sender, recipient, attrs \\ %{}, opts \\ [])
+
+ def ensure_direct_conversation(sender_pubkey, recipient_pubkey, attrs, opts)
+ when is_binary(sender_pubkey) and is_binary(recipient_pubkey) and is_map(attrs) do
+ attrs =
+ attrs
+ |> Map.put_new(:slug, direct_slug(sender_pubkey, recipient_pubkey))
+ |> Map.put_new(:title, "Direct Message")
+ |> Map.put(:conversation_kind, :dm)
+ |> Map.put_new(:backend, :public_sync)
+
+ with {:ok, %Channel{} = channel} <- ensure_conversation(attrs, opts),
+ {:ok, _sender} <- ensure_participant(channel, sender_pubkey, %{role: :member}, opts),
+ {:ok, _recipient} <-
+ ensure_participant(channel, recipient_pubkey, %{role: :member}, opts) do
+ {:ok, channel}
+ end
+ end
+
+ def direct_slug(pubkey_a, pubkey_b) when is_binary(pubkey_a) and is_binary(pubkey_b) do
+ digest =
+ [pubkey_a, pubkey_b]
+ |> Enum.sort()
+ |> Enum.join(":")
+ |> then(&:crypto.hash(:sha256, &1))
+ |> Base.encode16(case: :lower)
+
+ "dm-" <> digest
end
def ash_opts(context \\ nil) do
@@ -68,6 +120,11 @@ defmodule Aether.Chat do
]
end
+ def ensure_conversation(attrs \\ %{}, opts \\ []) when is_map(attrs) do
+ backend = attrs |> atomize_known_keys() |> Map.get(:backend, :public_sync)
+ backend_module(backend).ensure_conversation(attrs, opts)
+ end
+
def ensure_channel(attrs \\ %{}, opts \\ []) when is_map(attrs) do
attrs = normalize_channel_attrs(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
@@ -86,6 +143,38 @@ defmodule Aether.Chat do
|> Ash.read_one(ash_opts)
end
+ defp get_existing_participant(channel_id, pubkey, ash_opts) do
+ Participant
+ |> Ash.Query.filter(channel_id == ^channel_id and pubkey == ^pubkey)
+ |> Ash.Query.limit(1)
+ |> Ash.read_one(ash_opts)
+ end
+
+ def ensure_participant(%Channel{} = channel, pubkey, attrs \\ %{}, opts \\ [])
+ when is_binary(pubkey) and is_map(attrs) do
+ attrs = atomize_known_keys(attrs)
+ ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
+
+ case get_existing_participant(channel.id, pubkey, ash_opts) do
+ {:ok, nil} ->
+ attrs
+ |> Map.merge(%{channel_id: channel.id, pubkey: pubkey})
+ |> Map.put_new(:role, :member)
+ |> Map.put_new(:metadata, %{})
+ |> create_participant(ash_opts)
+
+ {:ok, %Participant{} = participant} ->
+ {:ok, participant}
+
+ {:error, _reason} = error ->
+ error
+ end
+ end
+
+ def list_conversation_messages(%Channel{backend: backend} = channel, opts \\ []) do
+ backend_module(backend).list_messages(channel, opts)
+ end
+
def list_messages(channel_or_id, opts \\ []) do
channel_id = channel_id(channel_or_id)
limit = Keyword.get(opts, :limit, 100)
@@ -104,6 +193,10 @@ defmodule Aether.Chat do
end
end
+ def send_message(%Channel{backend: backend} = channel, attrs, opts \\ []) when is_map(attrs) do
+ backend_module(backend).send_message(channel, attrs, opts)
+ end
+
def post_message(%Channel{} = channel, attrs, opts \\ []) when is_map(attrs) do
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
@@ -113,6 +206,10 @@ defmodule Aether.Chat do
|> maybe_broadcast_message()
end
+ def subscribe_conversation(%Channel{backend: backend} = channel, pid \\ self(), opts \\ []) do
+ backend_module(backend).subscribe(channel, pid, opts)
+ end
+
def subscribe_channel(%Channel{} = channel), do: subscribe_channel(channel.id)
def subscribe_channel(channel_id) when is_binary(channel_id) do
@@ -194,6 +291,10 @@ defmodule Aether.Chat do
"context_type" -> Map.put(acc, :context_type, value)
"context_id" -> Map.put(acc, :context_id, value)
"metadata" -> Map.put(acc, :metadata, value)
+ "pubkey" -> Map.put(acc, :pubkey, value)
+ "user_id" -> Map.put(acc, :user_id, value)
+ "display_name" -> Map.put(acc, :display_name, value)
+ "role" -> Map.put(acc, :role, parse_atom(value))
"author_id" -> Map.put(acc, :author_id, value)
"author_pubkey" -> Map.put(acc, :author_pubkey, value)
"body" -> Map.put(acc, :body, value)
@@ -204,9 +305,16 @@ defmodule Aether.Chat do
defp parse_atom(value) when is_atom(value), do: value
defp parse_atom("public_sync"), do: :public_sync
+ defp parse_atom("nostr_nip17"), do: :nostr_nip17
+ defp parse_atom("nostr_nip04"), do: :nostr_nip04
defp parse_atom("marmot"), do: :marmot
defp parse_atom("group"), do: :group
defp parse_atom("context_group"), do: :context_group
+ defp parse_atom("dm"), do: :dm
+ defp parse_atom("legacy_dm"), do: :legacy_dm
+ defp parse_atom("marmot_group"), do: :marmot_group
+ defp parse_atom("owner"), do: :owner
+ defp parse_atom("member"), do: :member
defp parse_atom(_value), do: nil
defp context_slug(%{context_provider: provider, context_type: type, context_id: id})
diff --git a/lib/aether/chat/backend.ex b/lib/aether/chat/backend.ex
new file mode 100644
index 0000000..dae104d
--- /dev/null
+++ b/lib/aether/chat/backend.ex
@@ -0,0 +1,25 @@
+defmodule Aether.Chat.Backend do
+ @moduledoc """
+ Behaviour for chat storage/transport backends.
+
+ Backends are plain Elixir modules. Ash resources store conversation projections,
+ participants, and local message state, while protocol backends may use Parrhesia
+ raw events as canonical storage.
+
+ `opts` may carry signing/client context for future non-custodial clients, e.g.
+ browser-held Nostr signers, NIP-07 bridges, or server-session signers. Backends
+ that need signatures should reject missing signer context explicitly instead of
+ assuming server-side custody.
+ """
+
+ alias Aether.Chat.Channel
+
+ @type attrs :: map()
+ @type opts :: keyword()
+
+ @callback capabilities() :: map()
+ @callback ensure_conversation(attrs(), opts()) :: {:ok, Channel.t()} | {:error, term()}
+ @callback list_messages(Channel.t(), opts()) :: {:ok, [struct()]} | {:error, term()}
+ @callback send_message(Channel.t(), attrs(), opts()) :: {:ok, struct()} | {:error, term()}
+ @callback subscribe(Channel.t(), pid(), opts()) :: :ok | {:error, term()}
+end
diff --git a/lib/aether/chat/backends/marmot.ex b/lib/aether/chat/backends/marmot.ex
new file mode 100644
index 0000000..2535a2e
--- /dev/null
+++ b/lib/aether/chat/backends/marmot.ex
@@ -0,0 +1,41 @@
+defmodule Aether.Chat.Backends.Marmot do
+ @moduledoc """
+ Marmot/MLS group backend scaffold.
+
+ Marmot events should be stored canonically as Parrhesia/Nostr events while the
+ browser/client maintains MLS state. The current web UI does not claim true E2EE.
+ """
+
+ @behaviour Aether.Chat.Backend
+
+ alias Aether.Chat
+ alias Aether.Chat.Channel
+
+ @impl true
+ def capabilities do
+ %{
+ canonical_store: :parrhesia_events,
+ nostr_compatible?: true,
+ non_custodial_signing?: true,
+ conversation_kinds: [:marmot_group],
+ read_only?: false,
+ required_protocols: [:marmot, :mls]
+ }
+ end
+
+ @impl true
+ def ensure_conversation(attrs, opts) when is_map(attrs) do
+ attrs
+ |> Map.put(:backend, :marmot)
+ |> Chat.ensure_channel(opts)
+ end
+
+ @impl true
+ def list_messages(%Channel{}, _opts), do: {:ok, []}
+
+ @impl true
+ def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented}
+
+ @impl true
+ def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
+end
diff --git a/lib/aether/chat/backends/nostr_nip04_read_only.ex b/lib/aether/chat/backends/nostr_nip04_read_only.ex
new file mode 100644
index 0000000..46a3905
--- /dev/null
+++ b/lib/aether/chat/backends/nostr_nip04_read_only.ex
@@ -0,0 +1,36 @@
+defmodule Aether.Chat.Backends.NostrNip04ReadOnly do
+ @moduledoc """
+ Legacy NIP-04 DM read-only fallback scaffold.
+
+ NIP-04 is kept for compatibility/import views. New outbound DMs should prefer
+ NIP-17 once that backend is implemented.
+ """
+
+ @behaviour Aether.Chat.Backend
+
+ alias Aether.Chat.Channel
+
+ @impl true
+ def capabilities do
+ %{
+ canonical_store: :parrhesia_events,
+ nostr_compatible?: true,
+ non_custodial_signing?: true,
+ conversation_kinds: [:legacy_dm],
+ read_only?: true,
+ required_protocols: [:nip04]
+ }
+ end
+
+ @impl true
+ def ensure_conversation(_attrs, _opts), do: {:error, :not_implemented}
+
+ @impl true
+ def list_messages(%Channel{}, _opts), do: {:error, :not_implemented}
+
+ @impl true
+ def send_message(%Channel{}, _attrs, _opts), do: {:error, :read_only_backend}
+
+ @impl true
+ def subscribe(%Channel{}, _pid, _opts), do: {:error, :not_implemented}
+end
diff --git a/lib/aether/chat/backends/nostr_nip17.ex b/lib/aether/chat/backends/nostr_nip17.ex
new file mode 100644
index 0000000..4762299
--- /dev/null
+++ b/lib/aether/chat/backends/nostr_nip17.ex
@@ -0,0 +1,37 @@
+defmodule Aether.Chat.Backends.NostrNip17 do
+ @moduledoc """
+ NIP-17 private DM backend scaffold.
+
+ This backend is intended to use Parrhesia/Nostr events as canonical message
+ storage and a local Aether projection for UI state. It is not active until the
+ signer, gift-wrap, subscription, and projection pipeline is implemented.
+ """
+
+ @behaviour Aether.Chat.Backend
+
+ alias Aether.Chat.Channel
+
+ @impl true
+ def capabilities do
+ %{
+ canonical_store: :parrhesia_events,
+ nostr_compatible?: true,
+ non_custodial_signing?: true,
+ conversation_kinds: [:dm],
+ read_only?: false,
+ required_protocols: [:nip17, :nip44, :nip59]
+ }
+ end
+
+ @impl true
+ def ensure_conversation(_attrs, _opts), do: {:error, :not_implemented}
+
+ @impl true
+ def list_messages(%Channel{}, _opts), do: {:error, :not_implemented}
+
+ @impl true
+ def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented}
+
+ @impl true
+ def subscribe(%Channel{}, _pid, _opts), do: {:error, :not_implemented}
+end
diff --git a/lib/aether/chat/backends/public_sync.ex b/lib/aether/chat/backends/public_sync.ex
new file mode 100644
index 0000000..817c127
--- /dev/null
+++ b/lib/aether/chat/backends/public_sync.ex
@@ -0,0 +1,35 @@
+defmodule Aether.Chat.Backends.PublicSync do
+ @moduledoc """
+ Plaintext Ash-backed chat backend for public and local DM-style conversations.
+ """
+
+ @behaviour Aether.Chat.Backend
+
+ alias Aether.Chat
+ alias Aether.Chat.Channel
+
+ @impl true
+ def capabilities do
+ %{
+ canonical_store: :ash,
+ nostr_compatible?: false,
+ non_custodial_signing?: false,
+ conversation_kinds: [:group, :context_group, :dm],
+ read_only?: false
+ }
+ end
+
+ @impl true
+ def ensure_conversation(attrs, opts) when is_map(attrs), do: Chat.ensure_channel(attrs, opts)
+
+ @impl true
+ def list_messages(%Channel{} = channel, opts), do: Chat.list_messages(channel, opts)
+
+ @impl true
+ def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
+ Chat.post_message(channel, attrs, opts)
+ end
+
+ @impl true
+ def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
+end
diff --git a/lib/aether/chat/channel.ex b/lib/aether/chat/channel.ex
index a26a103..a417937 100644
--- a/lib/aether/chat/channel.ex
+++ b/lib/aether/chat/channel.ex
@@ -11,8 +11,8 @@ defmodule Aether.Chat.Channel do
import Ash.Expr
- @backends [:public_sync, :marmot]
- @conversation_kinds [:group, :context_group]
+ @backends [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
+ @conversation_kinds [:group, :context_group, :dm, :legacy_dm, :marmot_group]
postgres do
table("aether_chat_channels")
@@ -113,6 +113,11 @@ defmodule Aether.Chat.Channel do
destination_attribute(:channel_id)
public?(true)
end
+
+ has_many :participants, Aether.Chat.Participant do
+ destination_attribute(:channel_id)
+ public?(true)
+ end
end
attributes do
diff --git a/lib/aether/chat/participant.ex b/lib/aether/chat/participant.ex
new file mode 100644
index 0000000..29737d0
--- /dev/null
+++ b/lib/aether/chat/participant.ex
@@ -0,0 +1,127 @@
+defmodule Aether.Chat.Participant do
+ @moduledoc """
+ Participant projection for Aether conversations.
+ """
+
+ use Ash.Resource,
+ otp_app: :aether,
+ domain: Aether.Chat,
+ data_layer: AshPostgres.DataLayer,
+ extensions: [AshNostrSync]
+
+ import Ash.Expr
+
+ @roles [:owner, :member]
+
+ postgres do
+ table("aether_chat_participants")
+ repo(Tribes.Repo)
+
+ custom_indexes do
+ index([:channel_id, :pubkey], unique: true, where: "deleted_at IS NULL")
+ index([:pubkey], where: "deleted_at IS NULL")
+ end
+ end
+
+ nostr_sync do
+ namespace("plugins.aether.chat.participant")
+ lane(:control)
+ publish?(true)
+ consume?(true)
+ end
+
+ actions do
+ defaults([:read])
+
+ read :by_channel_pubkey do
+ get?(true)
+
+ argument :channel_id, :uuid do
+ allow_nil?(false)
+ end
+
+ argument :pubkey, :string do
+ allow_nil?(false)
+ end
+
+ filter(expr(channel_id == ^arg(:channel_id) and pubkey == ^arg(:pubkey)))
+ end
+
+ read :by_pubkey do
+ argument :pubkey, :string do
+ allow_nil?(false)
+ end
+
+ prepare(build(filter: expr(pubkey == ^arg(:pubkey)), sort: [inserted_at: :desc]))
+ end
+
+ create :create do
+ accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
+ change(AshNostrSync.PublishChange)
+ end
+
+ create :sync_upsert do
+ accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
+ upsert?(true)
+ end
+
+ update :update do
+ require_atomic?(false)
+ accept([:user_id, :display_name, :role, :metadata])
+ change(AshNostrSync.PublishChange)
+ end
+
+ destroy :destroy do
+ require_atomic?(false)
+ soft?(true)
+ soft_delete()
+ change(AshNostrSync.PublishChange)
+ end
+ end
+
+ relationships do
+ belongs_to :channel, Aether.Chat.Channel do
+ allow_nil?(false)
+ attribute_type(:uuid)
+ public?(true)
+ end
+ end
+
+ attributes do
+ attribute :id, :uuid do
+ allow_nil?(false)
+ primary_key?(true)
+ public?(true)
+ writable?(true)
+ default(&Ash.UUID.generate/0)
+ end
+
+ attribute :pubkey, :string do
+ allow_nil?(false)
+ public?(true)
+ end
+
+ attribute :user_id, :uuid do
+ public?(true)
+ end
+
+ attribute :display_name, :string do
+ public?(true)
+ end
+
+ attribute :role, :atom do
+ constraints(one_of: @roles)
+ allow_nil?(false)
+ default(:member)
+ public?(true)
+ end
+
+ attribute :metadata, :map do
+ allow_nil?(false)
+ default(%{})
+ public?(true)
+ end
+
+ timestamps(type: :utc_datetime)
+ end
+end
diff --git a/lib/aether_web/live/chat_live.ex b/lib/aether_web/live/chat_live.ex
index 6093dd5..04fa2b8 100644
--- a/lib/aether_web/live/chat_live.ex
+++ b/lib/aether_web/live/chat_live.ex
@@ -4,7 +4,7 @@ defmodule AetherWeb.ChatLive do
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias Aether.Chat
- alias AetherWeb.ChatPanelComponent
+ alias AetherWeb.{ChatPanelComponent, ChatRecipientPickerComponent}
alias Tribes.Plugin.Layouts
@component_id "aether-chat-panel-component"
@@ -26,6 +26,27 @@ defmodule AetherWeb.ChatLive do
{:noreply, socket}
end
+ def handle_info({:aether_chat, :recipient_selected, recipient}, socket) do
+ current_user = socket.assigns.current_user
+
+ if current_user && is_binary(recipient.pubkey_hex) do
+ attrs = %{
+ title: recipient_label(recipient),
+ metadata: %{"recipient_username" => recipient.username}
+ }
+
+ case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs) do
+ {:ok, channel} ->
+ {:noreply, push_navigate(socket, to: Chat.standalone_path(channel))}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, "Could not open conversation: #{inspect(reason)}")}
+ end
+ else
+ {:noreply, put_flash(socket, :error, "Sign in to start a direct message")}
+ end
+ end
+
@impl true
def render(%{chat_request: %{mode: :embedded}} = assigns) do
~H"""
@@ -59,6 +80,12 @@ defmodule AetherWeb.ChatLive do
Public synced group chat. Marmot-backed channels will come later without changing the chat surface.
+ <.live_component
+ :if={@current_user}
+ module={ChatRecipientPickerComponent}
+ id="aether-chat-recipient-picker"
+ current_user={@current_user}
+ />
@@ -105,6 +132,16 @@ defmodule AetherWeb.ChatLive do
defp request(mode, slug, backend), do: %{mode: mode, slug: slug, backend: backend}
+ defp recipient_label(%{display_name: display_name})
+ when is_binary(display_name) and display_name != "" do
+ display_name
+ end
+
+ defp recipient_label(%{username: username}) when is_binary(username) and username != "",
+ do: username
+
+ defp recipient_label(%{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> "…"
+
defp channel_title("general"), do: "General Chat"
defp channel_title(slug) do
diff --git a/lib/aether_web/live/chat_panel_component.ex b/lib/aether_web/live/chat_panel_component.ex
index 0e52349..1cf0d34 100644
--- a/lib/aether_web/live/chat_panel_component.ex
+++ b/lib/aether_web/live/chat_panel_component.ex
@@ -161,7 +161,7 @@ defmodule AetherWeb.ChatPanelComponent do
end
defp load_channel(socket, slug, backend) do
- case Chat.ensure_channel(%{slug: slug, title: channel_title(slug), backend: backend}) do
+ case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do
{:ok, %Channel{} = channel} ->
messages = load_messages(channel)
message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
@@ -192,7 +192,7 @@ defmodule AetherWeb.ChatPanelComponent do
defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do
if connected?(socket) do
- _ = Chat.subscribe_channel(channel)
+ _ = Chat.subscribe_conversation(channel)
assign(socket, :subscribed_channel_id, channel.id)
else
socket
@@ -211,7 +211,7 @@ defmodule AetherWeb.ChatPanelComponent do
metadata: %{"author_name" => user_attr(user, :username)}
}
- case Chat.post_message(socket.assigns.channel, attrs) do
+ case Chat.send_message(socket.assigns.channel, attrs) do
{:ok, message} ->
{:noreply, put_message(socket, message)}
@@ -242,7 +242,7 @@ defmodule AetherWeb.ChatPanelComponent do
end
defp load_messages(%Channel{} = channel) do
- case Chat.list_messages(channel) do
+ case Chat.list_conversation_messages(channel) do
{:ok, messages} -> messages
{:error, _reason} -> []
end
diff --git a/lib/aether_web/live/chat_recipient_picker_component.ex b/lib/aether_web/live/chat_recipient_picker_component.ex
new file mode 100644
index 0000000..8d582a3
--- /dev/null
+++ b/lib/aether_web/live/chat_recipient_picker_component.ex
@@ -0,0 +1,139 @@
+defmodule AetherWeb.ChatRecipientPickerComponent do
+ @moduledoc """
+ Recipient picker exposed as part of Aether's `chat@1` UI contract.
+ """
+
+ use Phoenix.LiveComponent
+
+ alias Tribes.Plugin.Services.Alliance
+ alias Tribes.Plugin.User
+ alias Tribes.Scope
+
+ @impl true
+ def update(assigns, socket) do
+ recipients = Map.get(assigns, :recipients) || load_recipients(Map.get(assigns, :current_user))
+
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign_new(:query, fn -> "" end)
+ |> assign(:recipients, recipients)}
+ end
+
+ @impl true
+ def handle_event("search", %{"recipient" => %{"query" => query}}, socket) do
+ {:noreply, assign(socket, :query, String.trim(query || ""))}
+ end
+
+ def handle_event("select", %{"pubkey" => pubkey}, socket) do
+ case Enum.find(socket.assigns.recipients, &(&1.pubkey_hex == pubkey)) do
+ %User{} = user -> send(self(), {:aether_chat, :recipient_selected, user})
+ _other -> :ok
+ end
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ New message
+
+
+
+
+
+
+
+
+ No matching users.
+
+
+
+
+ """
+ end
+
+ defp load_recipients(_current_user) do
+ scope = Scope.new()
+
+ with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
+ {:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
+ Enum.filter(users, &is_binary(&1.pubkey_hex))
+ else
+ _other -> []
+ end
+ end
+
+ defp filtered_recipients(recipients, query, current_user) do
+ current_pubkey = current_user && current_user.pubkey_hex
+ query = String.downcase(query || "")
+
+ recipients
+ |> Enum.reject(&(&1.pubkey_hex == current_pubkey))
+ |> Enum.filter(&matches_query?(&1, query))
+ end
+
+ defp matches_query?(_recipient, ""), do: true
+
+ defp matches_query?(recipient, query) do
+ [recipient.username, recipient.display_name, recipient.npub, recipient.pubkey_hex]
+ |> Enum.filter(&is_binary/1)
+ |> Enum.any?(&String.contains?(String.downcase(&1), query))
+ end
+
+ defp recipient_label(%User{display_name: display_name})
+ when is_binary(display_name) and display_name != "" do
+ display_name
+ end
+
+ defp recipient_label(%User{username: username}) when is_binary(username) and username != "",
+ do: username
+
+ defp recipient_label(%User{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> "…"
+
+ defp recipient_pubkey(%User{npub: npub}) when is_binary(npub) and npub != "", do: npub
+ defp recipient_pubkey(%User{pubkey_hex: pubkey}), do: pubkey
+
+ defp recipient_initial(recipient) do
+ recipient
+ |> recipient_label()
+ |> String.first()
+ |> Kernel.||("?")
+ |> String.upcase()
+ end
+end
diff --git a/priv/repo/migrations/20260525134853_add_chat_participants.exs b/priv/repo/migrations/20260525134853_add_chat_participants.exs
new file mode 100644
index 0000000..d9dfcd0
--- /dev/null
+++ b/priv/repo/migrations/20260525134853_add_chat_participants.exs
@@ -0,0 +1,66 @@
+defmodule Tribes.Repo.Migrations.AddChatParticipants do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ create table(:aether_chat_participants, primary_key: false) do
+ add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
+ add(:pubkey, :text, null: false)
+ add(:user_id, :uuid)
+ add(:display_name, :text)
+ add(:role, :text, null: false, default: "member")
+ add(:metadata, :map, null: false, default: %{})
+
+ add(:inserted_at, :utc_datetime,
+ null: false,
+ default: fragment("(now() AT TIME ZONE 'utc')")
+ )
+
+ add(:updated_at, :utc_datetime,
+ null: false,
+ default: fragment("(now() AT TIME ZONE 'utc')")
+ )
+
+ add(
+ :channel_id,
+ references(:aether_chat_channels,
+ column: :id,
+ name: "aether_chat_participants_channel_id_fkey",
+ type: :uuid,
+ prefix: "public"
+ ),
+ null: false
+ )
+
+ add(:deleted_at, :utc_datetime)
+ end
+
+ create(
+ index(:aether_chat_participants, [:pubkey],
+ where: "deleted_at IS NULL AND deleted_at IS NULL"
+ )
+ )
+
+ create(
+ index(:aether_chat_participants, [:channel_id, :pubkey],
+ unique: true,
+ where: "deleted_at IS NULL AND deleted_at IS NULL"
+ )
+ )
+ end
+
+ def down do
+ drop(constraint(:aether_chat_participants, "aether_chat_participants_channel_id_fkey"))
+
+ drop_if_exists(index(:aether_chat_participants, [:channel_id, :pubkey]))
+
+ drop_if_exists(index(:aether_chat_participants, [:pubkey]))
+
+ drop(table(:aether_chat_participants))
+ end
+end
diff --git a/priv/repo/resource_snapshots/repo/aether_chat_participants/20260525134854.json b/priv/repo/resource_snapshots/repo/aether_chat_participants/20260525134854.json
new file mode 100644
index 0000000..2c4bf79
--- /dev/null
+++ b/priv/repo/resource_snapshots/repo/aether_chat_participants/20260525134854.json
@@ -0,0 +1,209 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "pubkey",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "user_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "display_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "\"member\"",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "role",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "%{}",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "metadata",
+ "type": "map"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "aether_chat_participants_channel_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "aether_chat_channels"
+ },
+ "scale": null,
+ "size": null,
+ "source": "channel_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "deleted_at",
+ "type": "utc_datetime"
+ }
+ ],
+ "base_filter": "deleted_at IS NULL",
+ "check_constraints": [],
+ "create_table_options": null,
+ "custom_indexes": [
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "channel_id",
+ "pubkey"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "channel_id"
+ },
+ {
+ "type": "atom",
+ "value": "pubkey"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": null,
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": true,
+ "using": null,
+ "where": "deleted_at IS NULL"
+ },
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "pubkey"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "pubkey"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": null,
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": false,
+ "using": null,
+ "where": "deleted_at IS NULL"
+ }
+ ],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "FE4AA6C74C1C2B9D844C47CDFA9418A41D4ACAFD7B2EF6D15CDA264295CBD6B3",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Tribes.Repo",
+ "schema": null,
+ "table": "aether_chat_participants"
+}
\ No newline at end of file
diff --git a/test/aether/chat_backend_test.exs b/test/aether/chat_backend_test.exs
new file mode 100644
index 0000000..aa7663f
--- /dev/null
+++ b/test/aether/chat_backend_test.exs
@@ -0,0 +1,74 @@
+defmodule Aether.ChatBackendTest do
+ use Tribes.PluginTest.PageCase, plugin: Tribes.Plugins.Aether.Plugin
+
+ alias Aether.Chat
+
+ test "public sync DM flow sends a user-to-user message", %{
+ signed_in_conn: sender_conn,
+ current_user: sender,
+ conn: conn
+ } do
+ recipient = create_user!()
+
+ {:ok, view, _html} = live(sender_conn, "/aether/chat")
+
+ assert has_element?(view, "#chat-recipient-picker")
+
+ view
+ |> form("#chat-recipient-search-form", %{"recipient" => %{"query" => recipient.username}})
+ |> render_change()
+
+ view
+ |> element("#chat-recipient-#{recipient.id}")
+ |> render_click()
+
+ assert_redirect(
+ view,
+ Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
+ )
+
+ {:ok, sender_view, _html} =
+ live(
+ sender_conn,
+ Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
+ )
+
+ sender_view
+ |> form("#chat-message-form", %{"message" => %{"body" => "hello recipient"}})
+ |> render_submit()
+
+ assert has_element?(sender_view, "#chat-messages", "hello recipient")
+
+ recipient_conn = sign_in(conn, recipient.username)
+
+ {:ok, recipient_view, _html} =
+ live(
+ recipient_conn,
+ Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
+ )
+
+ assert has_element?(recipient_view, "#chat-messages", "hello recipient")
+ end
+
+ test "backend capabilities prepare Nostr-compatible non-custodial backends" do
+ assert %{canonical_store: :parrhesia_events, non_custodial_signing?: true} =
+ Chat.backend_capabilities(:nostr_nip17)
+
+ assert %{read_only?: true, required_protocols: [:nip04]} =
+ Chat.backend_capabilities(:nostr_nip04)
+ end
+
+ defp create_user! do
+ username = "chat_recipient_#{System.unique_integer([:positive])}"
+
+ {:ok, user} =
+ Ash.create(
+ Tribes.Accounts.User,
+ %{username: username, password: "password_123", password_confirmation: "password_123"},
+ action: :register_with_password,
+ domain: Tribes.Accounts
+ )
+
+ Tribes.Plugin.User.from_host(user)
+ end
+end