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