You've already forked tribes-plugin-template
367 lines
13 KiB
Elixir
367 lines
13 KiB
Elixir
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"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} current_user={@current_user}>
|
|
<div class="space-y-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">{gettext("%{count} notes", count: @note_count)}</div>
|
|
</div>
|
|
|
|
<%= 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">
|
|
{gettext("You are viewing this tribe as a guest.")}
|
|
</p>
|
|
<.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)")}
|
|
/>
|
|
<div class="card-actions justify-end">
|
|
<button id="join-tribe-button" type="submit" class="btn btn-primary btn-sm">
|
|
{gettext("Join tribe")}
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
<% 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">
|
|
<div class="card-body">
|
|
<.input field={@form[:content]} type="textarea" label={gettext("Post")} rows="3" />
|
|
<div class="card-actions justify-end">
|
|
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
|
|
{gettext("Post note")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</.form>
|
|
<% 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">
|
|
{gettext("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"
|
|
>
|
|
{gettext("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">
|
|
<.link navigate={~p"/u/#{note["pubkey"]}"} class="link link-hover font-medium">
|
|
{author_label(@profile_cache, note["pubkey"])}
|
|
</.link>
|
|
<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>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</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
|
|
|
|
_ ->
|
|
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
|