defmodule AnnotAtWeb.PostsLive do use AnnotAtWeb, :live_view alias AnnotAt.Atproto.StandardSite alias AnnotAt.Atproto.StandardSite.Document alias AnnotAt.Feeds.Client alias AnnotAt.Feeds.Entry alias AnnotAt.Publishing alias AnnotAt.Publishing.Post alias Phoenix.LiveView.AsyncResult alias Phoenix.LiveView.JS require Logger @impl Phoenix.LiveView def render(assigns) do ~H""" <.link navigate={~p"/sites/#{@site.id}"} class="text-sm font-bold text-ink/50 hover:text-ink"> ← {@site.url}

Posts

Publish your blog's posts to the ATmosphere.

<.async_result :let={feed} assign={@feed}> <:loading>
<.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading the feed…
<:failed :let={_}>

Couldn't read the feed.

{Enum.count(done(feed.entries, @posts))} of {length(feed.entries)} published

<.button variant="primary" shadow="secondary" disabled={ @publishing_all? or not any_pending?( feed.entries, @posts ) } phx-click={show_modal("publish-all-modal")} > <.icon :if={@publishing_all?} name="hero-arrow-path" class="size-5 animate-spin" /> {if @publishing_all?, do: "Publishing…", else: "Publish all"}

No posts in this feed.

<.cover_thumb entry={entry} />
{entry.title}
{entry.summary}
{Calendar.strftime(entry.published_at, "%b %d, %Y")} {cover_note(entry.cover_status)}
<.button :if={has_date?(entry)} variant="primary" size="sm" disabled={is_nil(entry.rkey) or publishing?(@publishing, entry)} phx-value-guid={entry.id} phx-click={"select_post" |> JS.push() |> show_modal("publish-modal")} > <.icon :if={publishing?(@publishing, entry)} name="hero-arrow-path" class="size-4 animate-spin" /> {if publishing?(@publishing, entry), do: "Publishing…", else: "Publish"} No date
Published
<.cover_thumb entry={entry} />
{entry.title}
{entry.summary}
<.icon name="hero-check" class="size-3.5" /> Published {entry_to_post( @posts, entry ).rkey} {cover_note(entry.cover_status)}
<.button variant="ghost" size="sm" disabled={publishing?(@publishing, entry)} phx-value-guid={entry.id} phx-click={ JS.push("select_post") |> show_modal("republish-modal") } > <.icon name="hero-arrow-path" class={["size-4", publishing?(@publishing, entry) && "animate-spin"]} /> {if publishing?(@publishing, entry), do: "Re-publishing…", else: "Re-publish"}
<.confirm_modal id="publish-modal" title="Publish this post" confirm="publish_post" cta="Publish" > This writes a site.standard.document record to your atproto repo, making it publicly discoverable. You can re-publish to update it later. <.confirm_modal id="republish-modal" title="Re-publish this post" confirm="republish_post" cta="Re-publish" > This overwrites the existing site.standard.document record with the latest content from your feed. <.confirm_modal id="publish-all-modal" title="Publish all posts" confirm="publish_all_post" cta="Publish all" > This writes a site.standard.document record for every unpublished post in your feed, making each publicly discoverable. You can re-publish to update them later.
""" end @impl Phoenix.LiveView def mount(%{"id" => id}, _session, socket) do site = Publishing.get_site!(socket.assigns.current_scope, id) posts = Map.new(Publishing.list_posts(site), &{&1.guid, &1}) socket = socket |> assign( page_title: "Posts", site: site, posts: posts, publishing_all?: false, selected_guid: nil, publishing: MapSet.new() ) |> load_feed(site) {:ok, socket} end @impl Phoenix.LiveView def handle_event("select_post", %{"guid" => guid}, socket) do {:noreply, assign(socket, selected_guid: guid)} end def handle_event("publish_post", _params, socket) do %{selected_guid: guid, site: site, current_scope: scope, feed: feed} = socket.assigns with %AsyncResult{ok?: true, result: %{entries: entries}} <- feed, %{published_at: %DateTime{}} = entry <- Enum.find(entries, &(&1.id == guid)) do socket = socket |> assign(publishing: MapSet.put(socket.assigns.publishing, guid)) |> start_async({:publish, guid}, fn -> create_document(scope, site, entry) end) {:noreply, socket} else reason -> Logger.warning("PostsLive: failed to publish", reason: inspect(reason)) {:noreply, put_flash(socket, :error, "Couldn't publish, try again.")} end end def handle_event("republish_post", _params, socket) do %{selected_guid: guid, site: site, current_scope: scope, feed: feed, posts: posts} = socket.assigns with %AsyncResult{ok?: true, result: %{entries: entries}} <- feed, %{published_at: %DateTime{}} = entry <- Enum.find( entries, &(&1.id == guid) ), {:ok, %Post{} = post} <- Map.fetch(posts, guid) do socket = socket |> assign(publishing: MapSet.put(socket.assigns.publishing, guid)) |> start_async({:publish, guid}, fn -> update_document(scope, site, post, entry) end) {:noreply, socket} else reason -> Logger.warning("PostsLive: failed to re-publish", reason: inspect(reason)) {:noreply, put_flash(socket, :error, "Couldn't re-publish, try again.")} end end def handle_event("publish_all_post", _params, socket) do %{site: site, current_scope: scope, feed: feed, posts: posts} = socket.assigns with %AsyncResult{ok?: true, result: %{entries: entries}} <- feed do to_publish = entries |> pending(posts) |> Enum.filter(fn post -> has_date?(post) && post.rkey end) socket = socket |> assign(publishing_all?: true) |> start_async(:publish_all, fn -> publish_all(scope, site, to_publish) end) {:noreply, socket} end end @impl Phoenix.LiveView def handle_async(:publish_all, {:ok, new_posts}, socket) do socket = socket |> assign(publishing_all?: false) |> assign(posts: Map.merge(socket.assigns.posts, new_posts)) {:noreply, socket} end def handle_async(:publish_all, {:exit, reason}, socket) do Logger.warning("PostsLive: failed to publish all", reason: inspect(reason)) socket = socket |> assign(publishing_all?: false) |> put_flash(:error, "Some posts couldn't be published.") {:noreply, socket} end def handle_async({:publish, guid}, {:ok, {:ok, post}}, socket) do {:noreply, assign(socket, publishing: MapSet.delete(socket.assigns.publishing, guid), posts: Map.put(socket.assigns.posts, guid, post) )} end def handle_async({:publish, guid}, result, socket) do Logger.warning("PostsLive: write failed", reason: inspect(result), guid: guid) socket = socket |> assign(publishing: MapSet.delete(socket.assigns.publishing, guid)) |> put_flash(:error, "Couldn't publish, try again.") {:noreply, socket} end def handle_async(:load_feed, {:ok, {:ok, %{feed: feed, posts: posts}}}, socket) do {:noreply, assign(socket, feed: AsyncResult.ok(socket.assigns.feed, feed), posts: posts )} end def handle_async(:load_feed, {:ok, reason}, socket) do Logger.warning("PostsLive: failed to load feed", reason: inspect(reason)) {:noreply, assign(socket, feed: AsyncResult.failed(socket.assigns.feed, reason))} end defp load_feed(socket, site) do if connected?(socket) do user_did = socket.assigns.current_scope.user.did socket |> assign(feed: AsyncResult.loading()) |> start_async(:load_feed, fn -> with {:ok, feed} <- Client.load(site.feed_url) do feed = Client.resolve_documents(feed, user_did) posts = Publishing.adopt(site, feed.entries) {:ok, %{feed: feed, posts: posts}} end end) else assign(socket, feed: AsyncResult.loading()) end end defp to_document(entry, site, user) do %Document{ rkey: entry.rkey, site: StandardSite.publication_uri(user.did, site.rkey), title: entry.title, path: path_of(entry.url), published_at: entry.published_at, description: entry.summary, text_content: text_content_of(entry.content), content: entry.content, cover_image: fetch_cover(entry), tags: entry.categories } end defp path_of(nil), do: nil defp path_of(url), do: URI.parse(url).path defp text_content_of(nil), do: nil defp text_content_of(content) do content |> LazyHTML.from_fragment() |> LazyHTML.text() end defp create_document(scope, site, entry) do document = to_document(entry, site, scope.user) with {:ok, _} <- StandardSite.put_document(scope.user.id, document) do Publishing.create_post(site, %{ guid: entry.id, rkey: document.rkey, content_hash: Entry.hash(entry) }) end end defp update_document(scope, site, %Post{} = post, entry) do document = to_document(entry, site, scope.user) with {:ok, _} <- StandardSite.put_document(scope.user.id, document) do Publishing.update_post(post, %{content_hash: Entry.hash(entry)}) end end defp publish_all(scope, site, entries) do Enum.reduce(entries, %{}, fn entry, acc -> case create_document(scope, site, entry) do {:ok, post} -> Map.put(acc, entry.id, post) _ -> acc end end) end defp pending(entries, posts) do Enum.reject(entries, &Map.has_key?(posts, &1.id)) end defp done(entries, posts) do Enum.filter(entries, &Map.has_key?(posts, &1.id)) end defp any_pending?(entries, posts) do entries |> pending(posts) |> Enum.any?(&has_date?/1) end defp has_date?(entry), do: match?(%DateTime{}, entry.published_at) defp publishing?(publishing, entry), do: MapSet.member?(publishing, entry.id) attr :id, :string, required: true attr :title, :string, required: true attr :confirm, :string, required: true attr :cta, :string, required: true slot :inner_block, required: true defp confirm_modal(assigns) do ~H""" <.modal id={@id}>

{@title}

{render_slot(@inner_block)}

<.button phx-click={hide_modal(@id)}>Cancel <.button id={"#{@id}-confirm"} variant="primary" shadow="secondary" phx-click={JS.push(@confirm) |> hide_modal(@id)} > {@cta}
""" end defp entry_to_post(posts, %Entry{} = entry) do result = Enum.find(posts, fn {_key, %Post{} = post} -> post.guid == entry.id end) case result do {_key, value} -> value _ -> nil end end defp fetch_cover(%Entry{cover_status: status, image: url}) when status in [:ok, :unknown] and is_binary(url) do case Client.fetch_image(url) do {:ok, image} -> image {:error, _} -> nil end end defp fetch_cover(_entry), do: nil defp cover_note(:too_large), do: "Cover image too large to publish (max 1MB)" defp cover_note(:not_image), do: "Cover image isn't a supported format" attr :entry, :map, required: true def cover_thumb(assigns) do ~H"""
<.icon name="hero-exclamation-triangle" class="size-5" />
<.icon name="hero-photo" class="size-5" />
""" end end