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