defmodule AetherWeb.TimelineLive do use TribesWeb, :live_view on_mount({TribesWeb.LiveUserAuth, :live_user_optional}) alias Tribes.Communities alias Tribes.Communities.Tribe 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) profile_cache = load_profile_cache(notes) note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new() socket = socket |> assign(:page_title, gettext("Aether")) |> assign(:current_scope, socket.assigns[:current_scope]) |> 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(:profile_cache, profile_cache) |> assign(:note_ids, note_ids) |> assign(:note_count, MapSet.size(note_ids)) |> assign(:form, to_form(%{"content" => ""}, as: :note)) |> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join)) |> assign(:subscription_ref, nil) |> stream_configure(:notes, dom_id: &("note-" <> &1["id"])) |> stream(:notes, notes) if connected?(socket) do {:ok, ref} = Nostr.subscribe_notes(self(), "aether", limit: 100) {:ok, assign(socket, :subscription_ref, ref)} else {:ok, socket} end 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, gettext("Sign in to join tribes"))} is_nil(socket.assigns.tribe) -> {:noreply, put_flash(socket, :error, gettext("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} -> socket = refresh_scope(socket) {:noreply, socket |> put_flash(:info, gettext("Joined tribe")) |> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join))} {:error, _reason} -> {:noreply, put_flash(socket, :error, gettext("Unable to join tribe"))} end end end def handle_event("post", %{"note" => %{"content" => content}}, socket) do content = String.trim(content) cond do socket.assigns[:current_user] == nil -> {:noreply, put_flash(socket, :error, gettext("Sign in to post"))} content == "" -> {:noreply, put_flash(socket, :error, gettext("Post cannot be empty"))} not can_post_to_scope?(socket) -> {:noreply, put_flash(socket, :error, gettext("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(:form, to_form(%{"content" => ""}, as: :note)) |> assign(:note_ids, note_ids) |> assign(:note_count, MapSet.size(note_ids)) |> stream_insert(:notes, event, at: 0)} else {:noreply, socket |> assign(:form, to_form(%{"content" => ""}, as: :note)) |> put_flash(:info, gettext("Posted outside current tribe scope"))} end {:error, reason} -> {:noreply, put_flash( socket, :error, gettext("Failed to publish: %{reason}", reason: inspect(reason)) )} end _other -> {:noreply, put_flash(socket, :error, gettext("Signing key unavailable in session"))} end end end @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 profile_cache = maybe_put_profile(socket.assigns.profile_cache, event["pubkey"]) if MapSet.member?(socket.assigns.note_ids, event["id"]) do {:noreply, assign(socket, :profile_cache, profile_cache)} else note_ids = MapSet.put(socket.assigns.note_ids, event["id"]) {:noreply, socket |> assign(:profile_cache, profile_cache) |> assign(:note_ids, note_ids) |> assign(:note_count, MapSet.size(note_ids)) |> stream_insert(:notes, event, at: 0)} end else {:noreply, socket} end end def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket} def handle_info({:parrhesia, :eose, _ref, _sub_id}, socket), do: {:noreply, socket} def handle_info({:parrhesia, :closed, _ref, _sub_id, _reason}, socket), do: {:noreply, socket} @impl true def terminate(_reason, socket) do if is_reference(socket.assigns[:subscription_ref]) do Nostr.unsubscribe(socket.assigns.subscription_ref) end :ok end @impl true def render(assigns) do ~H"""

{timeline_title(@tribe)}

{gettext("%{count} notes", count: @note_count)}
<%= if @tribe && !@is_tribe_member? do %>

{gettext("You are viewing this tribe as a guest.")}

<.form for={@join_form} id="join-tribe-form" phx-submit="join_tribe" class="space-y-3"> <.input field={@join_form[:invite_code]} type="text" label={gettext("Invite code (optional for public tribes)")} />
<% end %> <%= if @current_user && (@can_view_tribe? || is_nil(@tribe)) do %> <.form for={@form} id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
<.input field={@form[:content]} type="textarea" label={gettext("Post")} rows="3" />
<% end %> <%= if !@can_view_tribe? && @tribe do %>
{gettext("This tribe timeline is private until you join.")}
<% end %>
<.link navigate={~p"/u/#{note["pubkey"]}"} class="link link-hover font-medium"> {author_label(@profile_cache, note["pubkey"])}

{note["content"]}

""" 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 _ -> 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{} = 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? = tribe.visibility == :public || is_member %{tribe: tribe, member_pubkeys: member_pubkeys, member?: is_member, can_view?: can_view?} else _ -> %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true} end end defp load_scope(_current_user, _tribe_id), do: %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true} 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) end end defp note_in_scope?(_event, nil), do: true 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 Enum.filter(notes, ¬e_in_scope?(&1, member_pubkeys)) end defp load_profile_cache(notes) do notes |> Enum.map(& &1["pubkey"]) |> Enum.uniq() |> Enum.reduce(%{}, &maybe_put_profile(&2, &1)) end defp maybe_put_profile(cache, pubkey) do if Map.has_key?(cache, pubkey) do cache else case Nostr.get_profile(pubkey) do {:ok, nil} -> Map.put(cache, pubkey, nil) {:ok, event} -> Map.put(cache, pubkey, parse_profile(event)) {:error, _reason} -> Map.put(cache, pubkey, nil) end end end defp parse_profile(%{"content" => content}) when is_binary(content) do case JSON.decode(content) do {:ok, profile} when is_map(profile) -> profile _other -> %{} end end defp parse_profile(_event), do: %{} defp author_label(profile_cache, pubkey) do profile = Map.get(profile_cache, pubkey, %{}) || %{} profile["name"] || "#{String.slice(pubkey, 0, 8)}…" end defp timeline_title(nil), do: gettext("Global timeline") defp timeline_title(%Tribe{name: name}), do: gettext("%{name} timeline", name: name) end