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