diff --git a/lib/aether_web/live/timeline_live.ex b/lib/aether_web/live/timeline_live.ex index 1b701fb..6586ba7 100644 --- a/lib/aether_web/live/timeline_live.ex +++ b/lib/aether_web/live/timeline_live.ex @@ -7,23 +7,18 @@ defmodule AetherWeb.TimelineLive do alias Tribes.Nostr @impl true - def mount(params, session, socket) do - tribe_id = resolve_tribe_id(params, session) - scope = load_scope(socket.assigns.current_user, tribe_id) - - {:ok, notes} = Nostr.list_notes(limit: 100) - notes = filter_notes(notes, scope) + def mount(_params, _session, socket) do + feed = load_feed() + notes = load_notes(feed.member_pubkeys) profile_cache = load_profile_cache(notes) note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new() socket = socket |> assign(:page_title, "Aether") - |> assign(:tribe_id, tribe_id) - |> assign(:tribe, scope.tribe) - |> assign(:can_view_tribe?, scope.can_view?) - |> assign(:is_tribe_member?, scope.member?) - |> assign(:tribe_member_pubkeys, scope.member_pubkeys) + |> assign(:current_scope, socket.assigns[:current_scope]) + |> assign(:tribe, feed.tribe) + |> assign(:tribe_member_pubkeys, feed.member_pubkeys) |> assign(:profile_cache, profile_cache) |> assign(:note_ids, note_ids) |> assign(:note_count, MapSet.size(note_ids)) @@ -32,7 +27,7 @@ defmodule AetherWeb.TimelineLive do |> stream(:notes, notes) if connected?(socket) do - {:ok, ref} = Nostr.subscribe_notes(self(), "aether", limit: 100) + {:ok, ref} = subscribe_notes(feed.member_pubkeys) {:ok, assign(socket, :subscription_ref, ref)} else {:ok, socket} @@ -40,31 +35,6 @@ defmodule AetherWeb.TimelineLive do end @impl true - def handle_event("join_tribe", %{"join" => %{"invite_code" => invite_code}}, socket) do - cond do - is_nil(socket.assigns.current_user) -> - {:noreply, put_flash(socket, :error, "Sign in to join tribes")} - - is_nil(socket.assigns.tribe) -> - {:noreply, put_flash(socket, :error, "No tribe selected")} - - true -> - opts = - case String.trim(invite_code || "") do - "" -> [] - code -> [invite_code: code] - end - - case Communities.join_tribe(socket.assigns.current_user, socket.assigns.tribe.id, opts) do - {:ok, _membership} -> - {:noreply, socket |> refresh_scope() |> put_flash(:info, "Joined tribe")} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Unable to join tribe")} - end - end - end - def handle_event("post", %{"note" => %{"content" => content}}, socket) do content = String.trim(content) @@ -75,25 +45,12 @@ defmodule AetherWeb.TimelineLive do content == "" -> {:noreply, put_flash(socket, :error, "Post cannot be empty")} - not can_post_to_scope?(socket) -> - {:noreply, put_flash(socket, :error, "Join this tribe to post")} - true -> case socket.assigns[:session_privkey] do privkey when is_binary(privkey) -> case Nostr.publish_note(content, socket.assigns.current_user.pubkey_hex, privkey) do {:ok, event} -> - if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do - note_ids = MapSet.put(socket.assigns.note_ids, event["id"]) - - {:noreply, - socket - |> assign(:note_ids, note_ids) - |> assign(:note_count, MapSet.size(note_ids)) - |> stream_insert(:notes, event, at: 0)} - else - {:noreply, put_flash(socket, :info, "Posted outside current tribe scope")} - end + handle_published_note(socket, event) {:error, reason} -> {:noreply, put_flash(socket, :error, "Failed to publish: #{inspect(reason)}")} @@ -107,8 +64,7 @@ defmodule AetherWeb.TimelineLive do @impl true def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = event}, socket) do - if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) and - socket.assigns.can_view_tribe? do + if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do profile_cache = maybe_put_profile(socket.assigns.profile_cache, event["pubkey"]) if MapSet.member?(socket.assigns.note_ids, event["id"]) do @@ -144,178 +100,165 @@ defmodule AetherWeb.TimelineLive do @impl true def render(assigns) do ~H""" -
-
-

{timeline_title(@tribe)}

-
{@note_count} notes
-
+ +
+
+
+
+

+ Local feed +

+

+ {timeline_title(@tribe)} +

+

+ {timeline_description(@tribe)} +

+
+
+ {@note_count} notes +
+
+
- <%= if @tribe && !@is_tribe_member? do %> -
-
-

- You are viewing this tribe as a guest. -

-
-
- <% end %> - - <%= if @current_user && (@can_view_tribe? || is_nil(@tribe)) do %> -
-
- -
-
+ + <% else %> +
+ Sign in to publish notes from your local identity.
- - <% end %> + <% end %> - <%= if !@can_view_tribe? && @tribe do %> -
- This tribe timeline is private until you join. -
- <% end %> +
+ -
- - - + +
-
+ """ end - defp resolve_tribe_id(params, session) when is_map(params) do - case Map.get(params, "tribe_id") || Map.get(params, "id") do - tribe_id when is_binary(tribe_id) and tribe_id != "" -> - tribe_id + defp handle_published_note(socket, event) do + if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do + note_ids = MapSet.put(socket.assigns.note_ids, event["id"]) - _ -> - parse_tribe_id_from_plugin_path(session["plugin_path"]) - end - end - - defp resolve_tribe_id(_params, session), - do: parse_tribe_id_from_plugin_path(session["plugin_path"]) - - defp parse_tribe_id_from_plugin_path(path) when is_binary(path) do - case String.split(path, "/", trim: true) do - ["aether", tribe_id | _] -> tribe_id - _ -> nil - end - end - - defp parse_tribe_id_from_plugin_path(_path), do: nil - - defp load_scope(current_user, tribe_id) when is_binary(tribe_id) do - with {:ok, tribe} when is_map(tribe) <- Communities.get_tribe(tribe_id), - {:ok, members} <- Communities.list_tribe_members_for_timeline(tribe.id) do - member_pubkeys = members |> Enum.map(& &1.user.pubkey_hex) |> MapSet.new() - - is_member = - case current_user do - nil -> false - user -> Enum.any?(members, &(&1.user_id == user.id)) - end - - can_view? = Map.get(tribe, :visibility) == :public || is_member - - %{tribe: tribe, member_pubkeys: member_pubkeys, member?: is_member, can_view?: can_view?} + {:noreply, + socket + |> assign(:note_ids, note_ids) + |> assign(:note_count, MapSet.size(note_ids)) + |> stream_insert(:notes, event, at: 0)} else - _ -> %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true} + {:noreply, put_flash(socket, :info, "Posted outside the local tribe feed")} end end - defp load_scope(_current_user, _tribe_id), - do: %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true} + defp load_feed do + with {:ok, tribe} when is_map(tribe) <- Communities.singleton_tribe(authorize?: false), + {:ok, members} <- Communities.list_tribe_members(tribe.id, authorize?: false) do + member_pubkeys = + members + |> Enum.map(fn membership -> + case membership.user do + %{pubkey_hex: pubkey_hex} -> pubkey_hex + _ -> nil + end + end) + |> Enum.filter(&is_binary/1) + |> MapSet.new() - defp refresh_scope(socket) do - scope = load_scope(socket.assigns.current_user, socket.assigns.tribe_id) - - notes = - case Nostr.list_notes(limit: 100) do - {:ok, notes} -> filter_notes(notes, scope) - _ -> [] - end - - note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new() - - socket - |> assign(:tribe, scope.tribe) - |> assign(:can_view_tribe?, scope.can_view?) - |> assign(:is_tribe_member?, scope.member?) - |> assign(:tribe_member_pubkeys, scope.member_pubkeys) - |> assign(:note_ids, note_ids) - |> assign(:note_count, MapSet.size(note_ids)) - |> stream(:notes, notes, reset: true) - end - - defp can_post_to_scope?(socket) do - case socket.assigns.tribe_member_pubkeys do - nil -> true - pubkeys -> MapSet.member?(pubkeys, socket.assigns.current_user.pubkey_hex) + %{tribe: tribe, member_pubkeys: member_pubkeys} + else + _ -> %{tribe: nil, member_pubkeys: MapSet.new()} end end - defp note_in_scope?(_event, nil), do: true + defp load_notes(member_pubkeys) do + query_opts = + [limit: 100] + |> maybe_put_authors(member_pubkeys) + + case Nostr.list_notes(query_opts) do + {:ok, notes} -> + notes + |> filter_notes(member_pubkeys) + |> sort_notes() + + {:error, _reason} -> + [] + end + end + + defp subscribe_notes(member_pubkeys) do + query_opts = + [limit: 100] + |> maybe_put_authors(member_pubkeys) + + case Nostr.subscribe_notes(self(), "aether", query_opts) do + {:ok, ref} -> {:ok, ref} + {:error, _reason} -> {:ok, nil} + end + end defp note_in_scope?(event, member_pubkeys) do MapSet.member?(member_pubkeys, event["pubkey"]) end - defp filter_notes(_notes, %{can_view?: false}), do: [] - defp filter_notes(notes, %{member_pubkeys: nil}), do: notes - - defp filter_notes(notes, %{member_pubkeys: member_pubkeys}) do + defp filter_notes(notes, member_pubkeys) do Enum.filter(notes, ¬e_in_scope?(&1, member_pubkeys)) end + defp sort_notes(notes) do + Enum.sort_by(notes, fn note -> + {-Map.get(note, "created_at", 0), Map.get(note, "id", "")} + end) + end + defp load_profile_cache(notes) do notes |> Enum.map(& &1["pubkey"]) @@ -349,11 +292,29 @@ defmodule AetherWeb.TimelineLive do profile["name"] || "#{String.slice(pubkey, 0, 8)}…" end - defp timeline_title(nil), do: "Global timeline" + defp timeline_title(nil), do: "Aether" defp timeline_title(%{name: name}) when is_binary(name) do - "#{name} timeline" + "#{name} on Aether" end - defp timeline_title(_tribe), do: "Tribe timeline" + defp timeline_title(_tribe), do: "Aether" + + defp timeline_description(%{name: name}) when is_binary(name) do + "Showing notes from #{name} members first. Global discovery can come later without losing the local trust graph." + end + + defp timeline_description(_tribe) do + "Showing notes from local tribe members first. Sign in to publish from your local identity." + end + + defp maybe_put_authors(opts, member_pubkeys) do + authors = MapSet.to_list(member_pubkeys) + + if authors == [] do + opts + else + Keyword.put(opts, :authors, authors) + end + end end diff --git a/test/aether/host_integration_test.exs b/test/aether/host_integration_test.exs index 2e0fc94..50e7672 100644 --- a/test/aether/host_integration_test.exs +++ b/test/aether/host_integration_test.exs @@ -1,4 +1,4 @@ -if Code.ensure_loaded?(Tribes.PluginRegistry) and Code.ensure_loaded?(TribesWeb.Navigation) do +if Code.ensure_loaded?(Tribes.PluginRegistry) do defmodule Aether.HostIntegrationTest do use ExUnit.Case, async: false @@ -20,10 +20,10 @@ if Code.ensure_loaded?(Tribes.PluginRegistry) and Code.ensure_loaded?(TribesWeb. assert %{name: "aether"} = Tribes.PluginRegistry.provider!("aether@1") assert {:ok, "aether", %{path: "/aether"}} = - Tribes.PluginRegistry.page_for_path("/aether/tribe-123") + Tribes.PluginRegistry.page_for_path("/aether") - assert TribesWeb.Navigation.timeline_base_path() == "/aether" - assert TribesWeb.Navigation.timeline_path("tribe-123") == "/aether/tribe-123" + assert {:ok, "aether", %{path: "/aether"}} = + Tribes.PluginRegistry.page_for_path("/aether/tribe-123") end end end