From c7cc70c00ab375e4ab47c02a90682ccee0c3e04d Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Tue, 4 Aug 2026 17:46:28 +0100 Subject: [PATCH] Add delete button Make it possible to delete records --- lib/annot_at/publishing.ex | 5 + .../controllers/live/post_live.ex | 5 +- .../controllers/live/posts_live.ex | 138 +++++++++++++++++- test/annot_at/publishing_test.exs | 11 ++ test/annot_at_web/live/posts_live_test.exs | 59 ++++++++ 5 files changed, 210 insertions(+), 8 deletions(-) diff --git a/lib/annot_at/publishing.ex b/lib/annot_at/publishing.ex index 626602f..076f31b 100644 --- a/lib/annot_at/publishing.ex +++ b/lib/annot_at/publishing.ex @@ -103,6 +103,11 @@ defmodule AnnotAt.Publishing do ) end + def untrack_post(%Site{id: site_id}, rkey) do + Repo.delete_all(from p in Post, where: p.site_id == ^site_id and p.rkey == ^rkey) + :ok + end + defp verify_user_ownership!(%Site{user_id: user_id}, user_id), do: :ok defp verify_user_ownership!(%Site{}, _user_id), do: raise(Ecto.NoResultsError, queryable: Site) end diff --git a/lib/annot_at_web/controllers/live/post_live.ex b/lib/annot_at_web/controllers/live/post_live.ex index 82f765c..8d50ab9 100644 --- a/lib/annot_at_web/controllers/live/post_live.ex +++ b/lib/annot_at_web/controllers/live/post_live.ex @@ -3,6 +3,7 @@ defmodule AnnotAtWeb.PostLive do import AnnotAtWeb.DocumentComponents + alias AnnotAt.Accounts alias AnnotAt.Atproto alias AnnotAt.Atproto.StandardSite alias AnnotAt.Atproto.StandardSite.Document @@ -53,7 +54,9 @@ defmodule AnnotAtWeb.PostLive do end defp cover_url(user, %Document{cover_image: %{} = blob}) do - Atproto.blob_url(user.pds_host, user.did, blob) + if session = Accounts.get_atproto_session(user.did) do + Atproto.blob_url(session.pds_host, user.did, blob) + end end defp cover_url(_user, _doc), do: nil diff --git a/lib/annot_at_web/controllers/live/posts_live.ex b/lib/annot_at_web/controllers/live/posts_live.ex index 6c15990..3eb5c44 100644 --- a/lib/annot_at_web/controllers/live/posts_live.ex +++ b/lib/annot_at_web/controllers/live/posts_live.ex @@ -1,6 +1,8 @@ defmodule AnnotAtWeb.PostsLive do use AnnotAtWeb, :live_view + alias AnnotAt.Accounts + alias AnnotAt.Atproto alias AnnotAt.Atproto.StandardSite alias AnnotAt.Atproto.StandardSite.Document alias AnnotAt.Feeds.Client @@ -92,10 +94,10 @@ defmodule AnnotAtWeb.PostsLive do

- <.cover_thumb entry={entry} /> + <.document_thumb document={doc} pds_host={@pds_host} did={@current_scope.user.did} />
<.link @@ -134,6 +136,16 @@ defmodule AnnotAtWeb.PostsLive do /> {if publishing?(@publishing, entry), do: "Re-publishing…", else: "Re-publish"} + <.button + id={"remove-#{entry.rkey}"} + aria-label="Remove" + variant="ghost" + size="sm" + phx-value-rkey={entry.rkey} + phx-click={JS.push("select_post") |> show_modal("delete-modal")} + > + <.icon name="hero-trash" class="size-4" /> +
<% :unpublished -> %> @@ -185,11 +197,12 @@ defmodule AnnotAtWeb.PostsLive do

Every published document is in the current feed.

-
+
+ <.document_thumb document={doc} pds_host={@pds_host} did={@current_scope.user.did} />
<.link navigate={~p"/sites/#{@site.id}/posts/#{doc.rkey}"} @@ -197,13 +210,34 @@ defmodule AnnotAtWeb.PostsLive do > {doc.title} +
+ {doc.description} +
+ + <.icon name="hero-check" class="size-3.5" /> Published + {doc.rkey} {Calendar.strftime(doc.published_at, "%b %d, %Y")}
+ + <.button + id={"remove-#{doc.rkey}"} + aria-label="Remove" + variant="ghost" + size="sm" + phx-value-rkey={doc.rkey} + phx-click={JS.push("select_post") |> show_modal("delete-modal")} + > + <.icon name="hero-trash" class="size-4" /> +
<% :setup -> %> @@ -318,6 +352,18 @@ defmodule AnnotAtWeb.PostsLive do 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. + + <.confirm_modal + id="delete-modal" + title="Remove this post" + confirm="delete_post" + cta="Remove" + > + This deletes the site.standard.document + record from your atproto repo. The blog post itself is untouched, and you can + re-publish it later under the same record key. + @@ -328,6 +374,12 @@ defmodule AnnotAtWeb.PostsLive do def mount(%{"id" => id}, _session, socket) do site = Publishing.get_site!(socket.assigns.current_scope, id) posts = Map.new(Publishing.list_posts(site), &{&1.rkey, &1}) + user = socket.assigns.current_scope.user + + pds_host = + if session = Accounts.get_atproto_session(user.did) do + session.pds_host + end socket = socket @@ -337,7 +389,8 @@ defmodule AnnotAtWeb.PostsLive do posts: posts, publishing_all?: false, selected_rkey: nil, - publishing: MapSet.new() + publishing: MapSet.new(), + pds_host: pds_host ) |> load_feed(site) |> load_documents(site) @@ -401,6 +454,12 @@ defmodule AnnotAtWeb.PostsLive do end end + def handle_event("delete_post", _params, socket) do + %{selected_rkey: rkey, site: site, current_scope: scope} = socket.assigns + + {:noreply, start_async(socket, {:delete, rkey}, fn -> delete_document(scope, site, rkey) end)} + end + @impl Phoenix.LiveView def handle_async(:publish_all, {:ok, {new_docs, new_posts}}, socket) do socket = @@ -467,6 +526,21 @@ defmodule AnnotAtWeb.PostsLive do {:noreply, assign(socket, documents: AsyncResult.failed(socket.assigns.documents, reason))} end + def handle_async({:delete, rkey}, {:ok, :ok}, socket) do + socket = + socket + |> assign(posts: Map.delete(socket.assigns.posts, rkey)) + |> update_documents(fn documents -> Enum.reject(documents, &(&1.rkey == rkey)) end) + + {:noreply, socket} + end + + def handle_async({:delete, rkey}, result, socket) do + Logger.warning("PostsLive: delete failed", reason: inspect(result), rkey: rkey) + + {:noreply, put_flash(socket, :error, "Couldn't remove, try again.")} + end + defp load_feed(socket, site) do if connected?(socket) do user_did = socket.assigns.current_scope.user.did @@ -535,6 +609,19 @@ defmodule AnnotAtWeb.PostsLive do end end + defp delete_document(scope, site, rkey) do + case StandardSite.delete_document(scope.user.id, rkey) do + {:ok, _} -> + Publishing.untrack_post(site, rkey) + + {:error, %Latch.Error.XRPC{body: %{"error" => "RecordNotFound"}}} -> + Publishing.untrack_post(site, rkey) + + {:error, reason} -> + {:error, reason} + end + end + defp publish_all(scope, site, entries) do Enum.reduce(entries, {[], %{}}, fn entry, {documents, posts} -> case create_document(scope, site, entry) do @@ -545,19 +632,26 @@ defmodule AnnotAtWeb.PostsLive do end defp partition(entries, documents) do - document_rkeys = MapSet.new(documents, & &1.rkey) + documents_by_rkey = Map.new(documents, &{&1.rkey, &1}) groups = Enum.group_by(entries, fn entry -> cond do is_nil(entry.rkey) -> :setup - MapSet.member?(document_rkeys, entry.rkey) -> :published + Map.has_key?(documents_by_rkey, entry.rkey) -> :published true -> :unpublished end end) + published = Map.get(groups, :published, []) + + published_documents = + Enum.map(published, fn entry -> + {entry, Map.fetch!(documents_by_rkey, entry.rkey)} + end) + %{ - published: Map.get(groups, :published, []), + published: published_documents, unpublished: Map.get(groups, :unpublished, []), setup: Map.get(groups, :setup, []) } @@ -683,4 +777,34 @@ defmodule AnnotAtWeb.PostsLive do
""" end + + attr :document, :map, required: true + attr :pds_host, :string, default: nil + attr :did, :string, required: true + + defp document_thumb(assigns) do + ~H""" + +
+ <.icon name="hero-photo" class="size-5" /> +
+ """ + end + + defp document_cover_url(pds_host, did, %Document{cover_image: %{} = blob}) + when is_binary(pds_host) do + Atproto.blob_url(pds_host, did, blob) + end + + defp document_cover_url(_pds_host, _did, _document), do: nil end diff --git a/test/annot_at/publishing_test.exs b/test/annot_at/publishing_test.exs index cb76983..186f326 100644 --- a/test/annot_at/publishing_test.exs +++ b/test/annot_at/publishing_test.exs @@ -134,4 +134,15 @@ defmodule AnnotAt.PublishingTest do assert [%Post{content_hash: "bbb"}] = Publishing.list_posts(site) end end + + describe "untrack_post/2" do + test "forgets the tracked rkey", %{scope: scope} do + {:ok, site} = Publishing.create_site(scope, "https://example.com") + + assert {:ok, _} = Publishing.track_post(site, %{rkey: "3mope", content_hash: "aaa"}) + assert :ok = Publishing.untrack_post(site, "3mope") + + assert [] = Publishing.list_posts(site) + end + end end diff --git a/test/annot_at_web/live/posts_live_test.exs b/test/annot_at_web/live/posts_live_test.exs index 9fd17e8..f6eaa15 100644 --- a/test/annot_at_web/live/posts_live_test.exs +++ b/test/annot_at_web/live/posts_live_test.exs @@ -7,6 +7,7 @@ defmodule AnnotAtWeb.PostsLiveTest do alias AnnotAt.Accounts alias AnnotAt.Accounts.Scope alias AnnotAt.Atproto.StandardSite + alias AnnotAt.Atproto.StandardSite.Document alias AnnotAt.Feeds.Client alias AnnotAt.Feeds.Entry alias AnnotAt.Feeds.Feed @@ -217,6 +218,64 @@ defmodule AnnotAtWeb.PostsLiveTest do assert [%{rkey: ^rkey}] = Publishing.list_posts(site) end + test "removing a post deletes the record and flips the row back", %{conn: conn} do + user = create_user() + scope = Scope.for_user(user) + site = create_site(scope) + published_at = ~U[2024-10-02 13:00:00Z] + rkey = TID.at_time(published_at, 1) + + document = %Document{ + rkey: rkey, + site: StandardSite.publication_uri(user.did, site.rkey), + title: "First Post", + published_at: published_at + } + + feed = %Feed{ + title: "Blog", + entries: [ + %Entry{ + id: "guid-1", + url: "https://example.com/posts/first", + title: "First Post", + published_at: published_at + } + ] + } + + expect(StandardSite, :list_documents, fn _user_id -> {:ok, [document]} end) + expect(Client, :load, fn _url -> {:ok, feed} end) + + expect(Client, :resolve_documents, fn _feed, _did -> + %{feed | entries: Enum.map(feed.entries, &%{&1 | rkey: rkey})} + end) + + expect(StandardSite, :delete_document, fn user_id, doc_rkey -> + assert user.id == user_id + assert rkey == doc_rkey + {:ok, %{}} + end) + + {:ok, lv, _html} = + conn + |> init_test_session(%{user_id: user.id}) + |> live(~p"/sites/#{site.id}/posts") + + assert render_async(lv, 2000) =~ "Published (1)" + + lv + |> element("#remove-#{rkey}") + |> render_click() + + lv + |> element("#delete-modal-confirm") + |> render_click() + + assert render_async(lv, 2000) =~ "Nothing published from this feed yet." + assert [] = Publishing.list_posts(site) + end + defp create_user do {:ok, user} = Accounts.upsert_user(%{ -- 2.51.2