From 91dfdd1bcf755eec2478b32c1462918f395aa601 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Tue, 4 Aug 2026 21:51:50 +0100 Subject: [PATCH] List all publications Adds a new site tab that lists all publications, letting you import existing ones more easily. --- README.md | 5 +- .../controllers/live/import_live.ex | 216 ++++++++++++++++ .../controllers/live/sites_live.ex | 231 ++++++++++++++++-- lib/annot_at_web/router.ex | 1 + test/annot_at_web/live/import_live_test.exs | 84 +++++++ test/annot_at_web/live/sites_live_test.exs | 65 +++++ 6 files changed, 578 insertions(+), 24 deletions(-) create mode 100644 lib/annot_at_web/controllers/live/import_live.ex create mode 100644 test/annot_at_web/live/import_live_test.exs create mode 100644 test/annot_at_web/live/sites_live_test.exs diff --git a/README.md b/README.md index ee06384..77d2bf4 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,13 @@ annot.at will use information from your blog to publish your documents. From the - [x] Add site including verification - [x] Document cover images - [x] Document content with html content type +- [ ] Support multiple publications on the same site - [ ] RSS poller - [ ] standard.site document reader - [ ] Post to Bluesky - [ ] Let atproto drive things more - - [ ] List publications, including not explicitly added ones - - [ ] List documents, including not explicitly added ones + - [x] List publications, including not explicitly added ones + - [x] List documents, including not explicitly added ones - [ ] List dangling documents/documents that no longer validate against post etc ## Authenticating diff --git a/lib/annot_at_web/controllers/live/import_live.ex b/lib/annot_at_web/controllers/live/import_live.ex new file mode 100644 index 0000000..f44e66a --- /dev/null +++ b/lib/annot_at_web/controllers/live/import_live.ex @@ -0,0 +1,216 @@ +defmodule AnnotAtWeb.ImportLive do + use AnnotAtWeb, :live_view + + alias AnnotAt.Atproto.StandardSite + alias AnnotAt.Feeds.Client + alias AnnotAt.Publishing + alias AnnotAt.URL + alias Phoenix.LiveView.AsyncResult + + require Logger + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + + <.link + navigate={~p"/sites?tab=discovered"} + class="text-sm font-bold text-ink/50 hover:text-ink" + > + ← Discovered + +

Import publication

+

+ This publication already lives in your repo. Pick the feed to read posts from, + and + it's set up. +

+ + <.async_result :let={check} assign={@check}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Checking your .well-known… +
+ + <:failed :let={_}> +

Couldn't load that + publication.

+ + +
+
+ {check.pub["name"] || check.pub["url"]} +
+
{check.pub["url"]}
+
+ + <%= if check.verified? do %> +
+ <.icon name="hero-check-circle" class="size-5 text-green-600" /> + Verified, you control this domain. +
+ +

Pick a feed

+

+ Posts from this feed will show up in annot.at. +

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

Couldn't find feeds on this site.

+ + +

+ No feeds found on this site. annot.at reads your posts from an RSS or Atom feed — add one to your blog and make sure it's linked from your homepage, then <.link + href={~p"/sites/import/#{@rkey}"} + class="font-bold underline hover:text-ink" + > + reload this page + . +

+ +
+ +
+ + <% else %> +
+

+ This publication isn't verified yet. Host this file on your site, then + check again: +

+
+
Path
+ + /.well-known/site.standard.publication + +
Contents
+ {check.at_uri} +
+ <.button variant="primary" shadow="secondary" phx-click="recheck"> + <.icon name="hero-arrow-path" class="size-5" /> Check again + +
+ <% end %> + +
+ """ + end + + @impl Phoenix.LiveView + def mount(%{"rkey" => rkey}, _session, socket) do + user = socket.assigns.current_scope.user + + socket = + socket + |> assign( + page_title: "Import publication", + rkey: rkey, + check: AsyncResult.loading(), + feeds: nil + ) + |> start_async(:check, fn -> check_publication(user, rkey) end) + + {:ok, socket} + end + + @impl Phoenix.LiveView + def handle_event("recheck", _params, socket) do + user = socket.assigns.current_scope.user + rkey = socket.assigns.rkey + + socket = + socket + |> assign(check: AsyncResult.loading(), feeds: nil) + |> start_async(:check, fn -> check_publication(user, rkey) end) + + {:noreply, socket} + end + + def handle_event("pick_feed", %{"url" => feed_url}, socket) do + %{rkey: rkey, check: %{result: %{pub: pub}}, current_scope: scope} = socket.assigns + + case import_publication(scope, pub, rkey, feed_url) do + {:ok, site} -> + {:noreply, push_navigate(socket, to: ~p"/sites/#{site.id}")} + + {:error, reason} -> + Logger.warning("ImportLive: import failed", reason: inspect(reason)) + {:noreply, put_flash(socket, :error, "Couldn't import, try again.")} + end + end + + @impl Phoenix.LiveView + def handle_async(:check, {:ok, %{verified?: true} = result}, socket) do + socket = + socket + |> assign(check: AsyncResult.ok(socket.assigns.check, result)) + |> assign(feeds: AsyncResult.loading()) + |> start_async(:load_feeds, fn -> Client.discover(result.pub["url"]) end) + + {:noreply, socket} + end + + def handle_async(:check, {:ok, %{verified?: false} = result}, socket) do + {:noreply, assign(socket, check: AsyncResult.ok(socket.assigns.check, result))} + end + + def handle_async(:check, {:ok, {:error, reason}}, socket) do + Logger.warning("ImportLive: check failed", reason: inspect(reason)) + {:noreply, assign(socket, check: AsyncResult.failed(socket.assigns.check, reason))} + end + + def handle_async(:load_feeds, {:ok, {:ok, feeds}}, socket) do + {:noreply, assign(socket, feeds: AsyncResult.ok(socket.assigns.feeds, feeds))} + end + + def handle_async(:load_feeds, {:ok, {:error, reason}}, socket) do + Logger.warning("ImportLive: feed discovery failed", reason: inspect(reason)) + {:noreply, assign(socket, feeds: AsyncResult.failed(socket.assigns.feeds, reason))} + end + + defp check_publication(user, rkey) do + case StandardSite.get_publication(user.id, rkey) do + {:ok, pub} -> + at_uri = StandardSite.publication_uri(user.did, rkey) + verified? = StandardSite.verify_ownership(pub["url"], at_uri) == :ok + %{pub: pub, at_uri: at_uri, verified?: verified?} + + {:error, reason} -> + {:error, reason} + end + end + + defp import_publication(scope, pub, rkey, feed_url) do + with {:ok, site} <- Publishing.create_site(scope, URL.canonical(pub["url"])), + {:ok, site} <- Publishing.use_existing_publication(scope, site, rkey), + {:ok, site} <- Publishing.update_site(scope, site, %{feed_url: feed_url}) do + Publishing.mark_verified(scope, site) + end + end +end diff --git a/lib/annot_at_web/controllers/live/sites_live.ex b/lib/annot_at_web/controllers/live/sites_live.ex index e29453d..47c87e6 100644 --- a/lib/annot_at_web/controllers/live/sites_live.ex +++ b/lib/annot_at_web/controllers/live/sites_live.ex @@ -3,13 +3,15 @@ defmodule AnnotAtWeb.SitesLive do import AnnotAtWeb.SiteComponents, only: [site_row: 1] + alias AnnotAt.Atproto.StandardSite alias AnnotAt.Publishing alias AnnotAt.Publishing.Site + alias Phoenix.LiveView.AsyncResult + + require Logger @impl Phoenix.LiveView def render(assigns) do - assigns = assign(assigns, :visible, filter_sites(assigns.sites, assigns.filter)) - ~H"""
- -
+ @filter == f && "bg-ink text-paper", + @filter != f && "bg-paper hover:bg-sky-light" + ]} + > + {filter_label(f)} + + -
-

No sites here yet.

- <.site_row :for={site <- @visible} site={site} /> -
+
+

No sites here yet.

+ <.site_row :for={site <- @visible} site={site} /> +
+
+
+
+
{site.url}
+
Publication missing from your + repo
+
+ + <.button variant="ghost" size="sm" phx-value-id={site.id} phx-click="delete_site"> + <.icon name="hero-trash" class="size-4" /> Delete + +
+
+ <% :discovered -> %> + <.async_result :let={_pubs} assign={@publications}> + <:loading> +
+ <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading your + repo… +
+ + <:failed :let={_}> +

Couldn't read your repo.

+ + +

+ No new publications found in your repo. +

+ +
+
+
+
{pub.name || pub.url}
+
{pub.url}
+
{pub.rkey}
+
+ + <.link + navigate={~p"/sites/import/#{pub.rkey}"} + class="inline-flex flex-none items-center gap-1.5 rounded-xl border-2 + border-ink bg-ink px-4 py-1.5 text-xs font-bold text-paper transition-all + hover:-translate-y-0.5" + > + Import + +
+
+ + <% end %>
""" end @@ -53,12 +123,86 @@ defmodule AnnotAtWeb.SitesLive do @impl Phoenix.LiveView def mount(_params, _session, socket) do sites = Publishing.list_sites(socket.assigns.current_scope) - {:ok, assign(socket, page_title: "Sites", sites: sites, filter: "all")} + + socket = + socket + |> assign( + page_title: "Sites", + sites: sites, + filter: "all", + visible: sites, + broken: [], + discovered: [] + ) + |> load_publications() + + {:ok, socket} + end + + @impl Phoenix.LiveView + def handle_params(params, _uri, socket) do + tab = + if Map.get(params, "tab") == "discovered" do + :discovered + else + :setup + end + + {:noreply, assign(socket, tab: tab)} end @impl Phoenix.LiveView def handle_event("filter", %{"status" => status}, socket) do - {:noreply, assign(socket, filter: status)} + visible = filter_sites(socket.assigns.sites -- socket.assigns.broken, status) + + {:noreply, + assign(socket, + filter: status, + visible: visible + )} + end + + def handle_event("delete_site", %{"id" => id}, socket) do + scope = socket.assigns.current_scope + site = Publishing.get_site!(scope, id) + {:ok, _} = Publishing.delete_site(scope, site) + + sites = Enum.reject(socket.assigns.sites, &(&1.id == site.id)) + broken = Enum.reject(socket.assigns.broken, &(&1.id == site.id)) + + {:noreply, + assign(socket, + sites: sites, + broken: broken, + visible: filter_sites(sites -- broken, socket.assigns.filter) + )} + end + + @impl Phoenix.LiveView + def handle_async(:load_publications, {:ok, {:ok, publications}}, socket) do + rkeys = MapSet.new(publications, & &1.rkey) + broken = Enum.filter(socket.assigns.sites, &broken?(&1, rkeys)) + + {:noreply, + assign(socket, + publications: AsyncResult.ok(socket.assigns.publications, publications), + broken: broken, + discovered: discovered(publications, socket.assigns.sites), + visible: filter_sites(socket.assigns.sites -- broken, socket.assigns.filter) + )} + end + + def handle_async(:load_publications, {:ok, {:error, reason}}, socket) do + Logger.warning("SitesLive: failed to load publications", reason: inspect(reason)) + + {:noreply, + assign(socket, + publications: + AsyncResult.failed( + socket.assigns.publications, + reason + ) + )} end defp filter_sites(sites, "all"), do: sites @@ -72,4 +216,47 @@ defmodule AnnotAtWeb.SitesLive do defp filter_label(status) do String.capitalize(status) end + + defp load_publications(socket) do + if connected?(socket) do + user = socket.assigns.current_scope.user + + socket + |> assign(publications: AsyncResult.loading()) + |> start_async(:load_publications, fn -> StandardSite.list_publications(user.id) end) + else + assign(socket, publications: AsyncResult.loading()) + end + end + + defp broken?(site, publication_rkeys) do + is_binary(site.rkey) and not is_nil(site.published_at) and + not MapSet.member?(publication_rkeys, site.rkey) + end + + defp discovered(publications, sites) do + site_rkeys = MapSet.new(sites, & &1.rkey) + Enum.reject(publications, &MapSet.member?(site_rkeys, &1.rkey)) + end + + attr :patch, :string, required: true + attr :active, :boolean, required: true + slot :inner_block, required: true + + defp tab_link(assigns) do + ~H""" + <.link + patch={@patch} + class={[ + "rounded-full border-2 px-3 py-1 text-sm font-bold transition-colors", + if(@active, + do: "border-ink bg-ink text-paper", + else: "border-ink/15 text-ink/55 hover:border-ink/40 hover:text-ink" + ) + ]} + > + {render_slot(@inner_block)} + + """ + end end diff --git a/lib/annot_at_web/router.ex b/lib/annot_at_web/router.ex index 69800bf..278322c 100644 --- a/lib/annot_at_web/router.ex +++ b/lib/annot_at_web/router.ex @@ -44,6 +44,7 @@ defmodule AnnotAtWeb.Router do live "/dashboard", DashboardLive live "/sites", SitesLive live "/sites/new", SiteNewLive + live "/sites/import/:rkey", ImportLive live "/sites/:id", SiteLive live "/sites/:id/posts", PostsLive live "/sites/:id/posts/:rkey", PostLive diff --git a/test/annot_at_web/live/import_live_test.exs b/test/annot_at_web/live/import_live_test.exs new file mode 100644 index 0000000..9bb2b26 --- /dev/null +++ b/test/annot_at_web/live/import_live_test.exs @@ -0,0 +1,84 @@ +defmodule AnnotAtWeb.ImportLiveTest do + use AnnotAtWeb.ConnCase, async: true + use Mimic + + import Phoenix.LiveViewTest + + alias AnnotAt.Accounts + alias AnnotAt.Accounts.Scope + alias AnnotAt.Atproto.StandardSite + alias AnnotAt.Feeds.Client + alias AnnotAt.Feeds.Source + alias AnnotAt.Publishing + + @rkey "3mope7jyypk22" + + test "verified publication imports with the picked feed", %{conn: conn} do + user = create_user() + scope = Scope.for_user(user) + + expect(StandardSite, :get_publication, fn user_id, rkey -> + assert user.id == user_id + assert @rkey == rkey + {:ok, %{"name" => "New Blog", "url" => "https://new.example"}} + end) + + expect(StandardSite, :verify_ownership, fn "https://new.example", at_uri -> + assert "at://did:plc:abc/site.standard.publication/#{@rkey}" == at_uri + :ok + end) + + expect(Client, :discover, fn "https://new.example" -> + {:ok, [%Source{url: "https://new.example/feed.xml", title: "RSS", format: :rss}]} + end) + + {:ok, lv, _html} = + conn + |> init_test_session(%{user_id: user.id}) + |> live(~p"/sites/import/#{@rkey}") + + assert render_async(lv, 2000) =~ "Verified" + assert render(lv) =~ "https://new.example/feed.xml" + + lv + |> element("button[phx-value-url='https://new.example/feed.xml']") + |> render_click() + + assert [site] = Publishing.list_sites(scope) + assert "https://new.example" == site.url + assert @rkey == site.rkey + assert "https://new.example/feed.xml" == site.feed_url + assert %DateTime{} = site.verified_at + assert %DateTime{} = site.published_at + end + + test "unverified publication shows instructions instead of the feed picker", %{conn: conn} do + user = create_user() + + expect(StandardSite, :get_publication, fn _user_id, @rkey -> + {:ok, %{"name" => "New Blog", "url" => "https://new.example"}} + end) + + expect(StandardSite, :verify_ownership, fn _url, _at_uri -> {:error, :mismatch} end) + + {:ok, lv, _html} = + conn + |> init_test_session(%{user_id: user.id}) + |> live(~p"/sites/import/#{@rkey}") + + html = render_async(lv, 2000) + assert html =~ "verified yet" + assert html =~ "/.well-known/site.standard.publication" + refute html =~ "feed.xml" + end + + defp create_user do + {:ok, user} = + Accounts.upsert_user(%{ + did: "did:plc:abc", + handle: "jola.dev" + }) + + user + end +end diff --git a/test/annot_at_web/live/sites_live_test.exs b/test/annot_at_web/live/sites_live_test.exs new file mode 100644 index 0000000..3d14b84 --- /dev/null +++ b/test/annot_at_web/live/sites_live_test.exs @@ -0,0 +1,65 @@ +defmodule AnnotAtWeb.SitesLiveTest do + use AnnotAtWeb.ConnCase, async: true + use Mimic + + import Phoenix.LiveViewTest + + alias AnnotAt.Accounts + alias AnnotAt.Accounts.Scope + alias AnnotAt.Atproto.StandardSite + alias AnnotAt.Publishing + + test "discovered tab lists publications that aren't set up", %{conn: conn} do + user = create_user() + + expect(StandardSite, :list_publications, fn user_id -> + assert user.id == user_id + {:ok, [%{rkey: "3mope7jyypk22", url: "https://new.example", name: "New Blog"}]} + end) + + {:ok, lv, _html} = + conn + |> init_test_session(%{user_id: user.id}) + |> live(~p"/sites?tab=discovered") + + assert render_async(lv, 2000) =~ "New Blog" + assert render(lv) =~ "Import" + end + + test "a site whose publication is gone shows as broken and can be deleted", %{conn: conn} do + user = create_user() + scope = Scope.for_user(user) + {:ok, _site} = create_broken_site(scope) + + expect(StandardSite, :list_publications, fn _user_id -> {:ok, []} end) + + {:ok, lv, _html} = + conn + |> init_test_session(%{user_id: user.id}) + |> live(~p"/sites") + + assert render_async(lv, 2000) =~ "Publication missing" + + lv + |> element("button", "Delete") + |> render_click() + + refute render(lv) =~ "Publication missing" + assert [] = Publishing.list_sites(scope) + end + + defp create_user do + {:ok, user} = + Accounts.upsert_user(%{ + did: "did:plc:abc", + handle: "jola.dev" + }) + + user + end + + defp create_broken_site(scope) do + {:ok, site} = Publishing.create_site(scope, "https://gone.example") + Publishing.use_existing_publication(scope, site, "3mope7jyypk22") + end +end -- 2.51.2