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.
This commit is contained in:
2026-05-25 15:54:58 +02:00
parent a55bd9612d
commit 8b1990ee25
14 changed files with 950 additions and 11 deletions
+112 -4
View File
@@ -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})
+25
View File
@@ -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
+41
View File
@@ -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
@@ -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
+37
View File
@@ -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
+35
View File
@@ -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
+7 -2
View File
@@ -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
+127
View File
@@ -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
+38 -1
View File
@@ -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.
</p>
</div>
<.live_component
:if={@current_user}
module={ChatRecipientPickerComponent}
id="aether-chat-recipient-picker"
current_user={@current_user}
/>
</div>
</section>
@@ -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
+4 -4
View File
@@ -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
@@ -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"""
<details id="chat-recipient-picker" class="dropdown dropdown-end">
<summary id="chat-recipient-picker-button" class="btn btn-primary btn-sm">
New message
</summary>
<div class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-xl">
<form id="chat-recipient-search-form" phx-change="search" phx-target={@myself}>
<label class="input input-sm input-bordered flex items-center gap-2">
<span class="text-xs text-base-content/50">To</span>
<input
id="chat-recipient-search"
name="recipient[query]"
value={@query}
type="search"
class="grow"
placeholder="Search tribe users"
/>
</label>
</form>
<div id="chat-recipient-options" class="mt-3 max-h-72 space-y-1 overflow-y-auto">
<button
:for={recipient <- filtered_recipients(@recipients, @query, @current_user)}
id={"chat-recipient-#{recipient.id}"}
type="button"
class="flex w-full items-center gap-3 rounded-box px-3 py-2 text-left hover:bg-base-200"
phx-click="select"
phx-target={@myself}
phx-value-pubkey={recipient.pubkey_hex}
>
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{recipient_initial(recipient)}
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-medium text-base-content">{recipient_label(recipient)}</span>
<span class="block truncate text-xs text-base-content/50">{recipient_pubkey(recipient)}</span>
</span>
</button>
<p
:if={filtered_recipients(@recipients, @query, @current_user) == []}
id="chat-recipient-empty"
class="rounded-box border border-dashed border-base-300 p-4 text-center text-sm text-base-content/60"
>
No matching users.
</p>
</div>
</div>
</details>
"""
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
@@ -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
@@ -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"
}
+74
View File
@@ -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