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""" ++ No matching users. +
+