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""" + + """ + 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