You've already forked tribes-plugin-aether
forked from tribes/tribes-plugin-template
Render Aether in Tribes layout
This commit is contained in:
@@ -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"""
|
||||
<div class="space-y-6 p-6" id="aether-page">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{timeline_title(@tribe)}</h1>
|
||||
<div class="badge badge-outline">{@note_count} notes</div>
|
||||
</div>
|
||||
<TribesWeb.Layouts.app
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
current_user={@current_user}
|
||||
>
|
||||
<div class="space-y-6 p-6" id="aether-page">
|
||||
<section class="rounded-3xl border border-base-300 bg-base-100/95 p-6 shadow-sm">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
|
||||
Local feed
|
||||
</p>
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-base-content">
|
||||
{timeline_title(@tribe)}
|
||||
</h1>
|
||||
<p class="max-w-2xl text-sm leading-6 text-base-content/70">
|
||||
{timeline_description(@tribe)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-full border border-base-300 px-4 py-2 text-sm font-medium text-base-content/70">
|
||||
{@note_count} notes
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= if @tribe && !@is_tribe_member? do %>
|
||||
<div class="card border border-base-300 bg-base-100 shadow-sm" id="tribe-join-card">
|
||||
<div class="card-body gap-3">
|
||||
<p class="text-sm text-base-content/80">
|
||||
You are viewing this tribe as a guest.
|
||||
</p>
|
||||
<form id="join-tribe-form" phx-submit="join_tribe" class="space-y-3">
|
||||
<label class="fieldset mb-2" for="join-invite-code">
|
||||
<span class="label mb-1">Invite code (optional for public tribes)</span>
|
||||
<input
|
||||
id="join-invite-code"
|
||||
name="join[invite_code]"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
/>
|
||||
<%= if @current_user do %>
|
||||
<form id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<label class="fieldset mb-2" for="note-content">
|
||||
<span class="label mb-1">Post</span>
|
||||
<textarea
|
||||
id="note-content"
|
||||
name="note[content]"
|
||||
rows="3"
|
||||
class="textarea w-full"
|
||||
placeholder="Share something with your local network"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="card-actions justify-end">
|
||||
<button id="join-tribe-button" type="submit" class="btn btn-primary btn-sm">
|
||||
Join tribe
|
||||
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
|
||||
Post note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @current_user && (@can_view_tribe? || is_nil(@tribe)) do %>
|
||||
<form id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<label class="fieldset mb-2" for="note-content">
|
||||
<span class="label mb-1">Post</span>
|
||||
<textarea id="note-content" name="note[content]" rows="3" class="textarea w-full">
|
||||
</textarea>
|
||||
</label>
|
||||
<div class="card-actions justify-end">
|
||||
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
|
||||
Post note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<% else %>
|
||||
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
|
||||
Sign in to publish notes from your local identity.
|
||||
</div>
|
||||
</form>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if !@can_view_tribe? && @tribe do %>
|
||||
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
|
||||
This tribe timeline is private until you join.
|
||||
</div>
|
||||
<% end %>
|
||||
<div id="notes" phx-update="stream" class="space-y-3">
|
||||
<div
|
||||
id="notes-empty"
|
||||
class="hidden only:block rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-sm text-base-content/60"
|
||||
>
|
||||
No local notes yet.
|
||||
</div>
|
||||
|
||||
<div id="notes" phx-update="stream" class="space-y-3">
|
||||
<div
|
||||
id="notes-empty"
|
||||
class="hidden only:block rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-sm text-base-content/60"
|
||||
>
|
||||
No notes yet.
|
||||
</div>
|
||||
|
||||
<article :for={{id, note} <- @streams.notes} id={id} class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-2">
|
||||
<div class="flex items-center justify-between gap-4 text-xs text-base-content/60">
|
||||
<a href={"/u/#{note["pubkey"]}"} class="link link-hover font-medium">
|
||||
{author_label(@profile_cache, note["pubkey"])}
|
||||
</a>
|
||||
<span
|
||||
phx-hook="DateTime"
|
||||
id={"time-#{note["id"]}"}
|
||||
phx-update="ignore"
|
||||
data-timestamp={note["created_at"] * 1000}
|
||||
data-relative="true"
|
||||
data-format="LLL"
|
||||
>
|
||||
</span>
|
||||
<article :for={{id, note} <- @streams.notes} id={id} class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-2">
|
||||
<div class="flex items-center justify-between gap-4 text-xs text-base-content/60">
|
||||
<a href={"/u/#{note["pubkey"]}"} class="link link-hover font-medium">
|
||||
{author_label(@profile_cache, note["pubkey"])}
|
||||
</a>
|
||||
<span
|
||||
phx-hook="DateTime"
|
||||
id={"time-#{note["id"]}"}
|
||||
phx-update="ignore"
|
||||
data-timestamp={note["created_at"] * 1000}
|
||||
data-relative="true"
|
||||
data-format="LLL"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-6">{note["content"]}</p>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-6">{note["content"]}</p>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TribesWeb.Layouts.app>
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user