annot.at is a service for syncing static websites, such as blogs, to atproto and standard.site annot.at
elixir atproto standardsite
3

Configure Feed

Select the types of activity you want to include in your feed.

Add a nicer login experience

Uses typeahead from typeahead.waow.tech and fancy avatars and a combobox and stuff. Supports up and down arrow keys and whatever else you might want.

Johanna Larsson (Jun 28, 2026, 6:45 PM +0100) fcb9e182 4a03b667

+453 -121
-13
assets/js/app.js
··· 48 48 // >> liveSocket.disableLatencySim() 49 49 window.liveSocket = liveSocket; 50 50 51 - // Login is a DeadView form, this adds a nice little spinner for it 52 - document.addEventListener("submit", (e) => { 53 - if (e.target?.id !== "login-form") return; 54 - 55 - const btn = e.target.querySelector("button[type='submit']"); 56 - 57 - if (!btn) return; 58 - 59 - btn.disabled = true; 60 - btn.innerHTML = 61 - '<span class="inline-block size-5 animate-spin rounded-full border-2 border-current border-t-transparent"></span>Connecting…'; 62 - }); 63 - 64 51 // The lines below enable quality of life phoenix_live_reload 65 52 // development features: 66 53 //
+45
lib/annot_at/atproto/directory.ex
··· 1 + defmodule AnnotAt.Atproto.Directory do 2 + @moduledoc """ 3 + Public atproto handle search, used for login page typeahead. 4 + """ 5 + 6 + alias AnnotAt.Atproto.HTTP 7 + 8 + @endpoint "https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead" 9 + 10 + @type suggestion :: %{ 11 + handle: String.t(), 12 + display_name: String.t() | nil, 13 + avatar: String.t() | nil 14 + } 15 + 16 + @doc """ 17 + Search typeahead.waow.tech for matching handles, passing the x-client header 18 + for attribution. Doesn't really do error handling to avoid spamming logs, and 19 + it's not a critical code path. 20 + """ 21 + @spec search_handles(String.t()) :: [suggestion()] 22 + def search_handles(query) do 23 + encoded_query = URI.encode_query(q: query, limit: 6) 24 + 25 + url = 26 + @endpoint 27 + |> URI.new!() 28 + |> URI.append_query(encoded_query) 29 + |> URI.to_string() 30 + 31 + case HTTP.get_json(url, headers: [{"x-client", "annot.at"}]) do 32 + {:ok, %{"actors" => actors}} -> 33 + Enum.map(actors, fn actor -> 34 + %{ 35 + handle: actor["handle"], 36 + display_name: actor["displayName"], 37 + avatar: actor["avatar"] 38 + } 39 + end) 40 + 41 + _ -> 42 + [] 43 + end 44 + end 45 + end
+6 -4
lib/annot_at/atproto/http.ex
··· 17 17 @spec get_json(String.t()) :: 18 18 {:ok, map()} 19 19 | {:error, {:http_status, pos_integer()} | {:transport, term()} | :invalid_json} 20 - def get_json(url) when is_binary(url) do 21 - with {:ok, body} <- get_body(url) do 20 + def get_json(url, opts \\ []) when is_binary(url) do 21 + with {:ok, body} <- get_body(url, opts) do 22 22 case Jason.decode(body) do 23 23 {:ok, %{} = json} -> {:ok, json} 24 24 _ -> {:error, :invalid_json} ··· 94 94 defp method_atom("GET"), do: :get 95 95 defp method_atom("POST"), do: :post 96 96 97 - defp get_body(url) when is_binary(url) do 98 - case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 97 + defp get_body(url, opts \\ []) when is_binary(url) do 98 + opts = Keyword.merge([decode_body: false, receive_timeout: @receive_timeout], opts) 99 + 100 + case Req.get(url, opts) do 99 101 {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} 100 102 {:ok, %Req.Response{status: status}} -> {:error, {:http_status, status}} 101 103 {:error, reason} -> {:error, {:transport, reason}}
-17
lib/annot_at_web/controllers/auth_controller.ex
··· 7 7 alias AnnotAt.Atproto.OAuth.Config 8 8 alias AnnotAt.Atproto.OAuth.Login 9 9 10 - def new(conn, _params) do 11 - render(conn, :new, handle: "") 12 - end 13 - 14 - def create(conn, %{"handle" => handle}) do 15 - case Login.start_login(handle) do 16 - {:ok, url} -> 17 - redirect(conn, external: url) 18 - 19 - {:error, reason} -> 20 - conn 21 - |> put_flash(:error, error_message(reason)) 22 - |> render(:new, handle: handle) 23 - end 24 - end 25 - 26 10 def callback(conn, params) do 27 11 case Login.complete_login(params) do 28 12 {:ok, user} -> ··· 56 40 json(conn, metadata) 57 41 end 58 42 59 - defp error_message(:invalid_handle), do: "That doesn't look like a valid handle." 60 43 defp error_message(:invalid_state), do: "Your login link expired. Please try again." 61 44 defp error_message({:oauth_error, _}), do: "Authorization was denied or failed." 62 45 defp error_message(_reason), do: "Something went wrong. Please try again."
-47
lib/annot_at_web/controllers/auth_html/new.html.heex
··· 1 - <Layouts.app flash={@flash} current_scope={@current_scope}> 2 - <div class="flex min-h-screen flex-col items-center justify-center px-6 py-12"> 3 - <.link href={~p"/"} class="mb-8 font-display text-2xl font-bold tracking-tight"> 4 - annot.at 5 - </.link> 6 - 7 - <div class="w-full max-w-sm -rotate-1 rounded-3xl border-2 border-ink bg-paper p-8 shadow-[10px_10px_0px_0px_var(--color-sky-bold)] transition-transform focus-within:rotate-0"> 8 - <h1 class="font-display text-3xl font-bold tracking-tight">Sign in</h1> 9 - <p class="mt-2 text-sm text-ink/60"> 10 - Enter your atproto handle to publish to the ATmosphere. 11 - </p> 12 - 13 - <.form for={%{}} action={~p"/login"} method="post" id="login-form" class="mt-6 space-y-4"> 14 - <div> 15 - <label for="handle" class="mb-1.5 block text-sm font-bold">Handle</label> 16 - <input 17 - type="text" 18 - name="handle" 19 - id="handle" 20 - value={@handle} 21 - placeholder="alice.bsky.social" 22 - required 23 - autofocus 24 - autocapitalize="none" 25 - autocorrect="off" 26 - spellcheck="false" 27 - class="w-full rounded-xl border-2 border-ink bg-paper px-4 py-3 placeholder:text-ink/35 focus:outline-none focus:ring-4 focus:ring-sky-bold/40" 28 - /> 29 - </div> 30 - <button 31 - type="submit" 32 - class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-ink px-5 py-3 font-bold text-paper transition-all hover:scale-[1.01] active:scale-[0.99] disabled:opacity-70" 33 - > 34 - Continue 35 - </button> 36 - </.form> 37 - 38 - <p class="mt-5 text-center text-xs text-ink/50"> 39 - Any atproto handle works, Bluesky or your own domain. 40 - </p> 41 - </div> 42 - 43 - <.link href={~p"/"} class="mt-8 text-sm text-ink/55 transition hover:text-ink"> 44 - ← Back to home 45 - </.link> 46 - </div> 47 - </Layouts.app>
+298
lib/annot_at_web/controllers/live/login_live.ex
··· 1 + defmodule AnnotAtWeb.LoginLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + alias AnnotAt.Atproto.Directory 5 + alias AnnotAt.Atproto.OAuth.Login 6 + 7 + @min_query_length 2 8 + 9 + @impl Phoenix.LiveView 10 + def render(assigns) do 11 + ~H""" 12 + <Layouts.app flash={@flash} current_scope={@current_scope}> 13 + <div class="flex min-h-screen flex-col items-center justify-center px-6 py-12"> 14 + <.link href={~p"/"} class="mb-8 font-display text-2xl font-bold tracking-tight"> 15 + annot.at 16 + </.link> 17 + 18 + <div class="w-full max-w-sm -rotate-1 rounded-3xl border-2 border-ink bg-paper p-8 shadow-[10px_10px_0px_0px_var(--color-sky-bold)] transition-transform focus-within:rotate-0"> 19 + <h1 class="font-display text-3xl font-bold tracking-tight"> 20 + Sign in 21 + </h1> 22 + 23 + <p class="mt-2 text-sm text-ink/60"> 24 + Enter your atproto handle to Publish to the ATmosphere. 25 + </p> 26 + 27 + <.form for={@form} phx-change="suggest" phx-submit="login" class="mt-6 space-y-4"> 28 + <div 29 + id="handle-combobox" 30 + class="relative" 31 + phx-hook=".HandleNav" 32 + phx-click-away={@open && "close"} 33 + > 34 + <label for="handle" class="mb-1.5 block text-sm font-bold">Handle</label> 35 + <div class="relative"> 36 + <span class="pointer-events-none absolute left-3 top-1/2 size-8 -translate-y-1/2"> 37 + <span 38 + :if={!@selected} 39 + class="absolute inset-0 flex items-center justify-center rounded-full border-2 border-dashed border-ink/25 text-ink/30" 40 + > 41 + <.icon name="hero-user" class="size-4" /> 42 + </span> 43 + <span 44 + :if={@selected} 45 + class="absolute inset-0 flex items-center justify-center rounded-full border-2 border-ink bg-sky-bold/30 text-sm font-bold" 46 + > 47 + {@selected.handle |> String.first() |> String.upcase()} 48 + </span> 49 + <img 50 + :if={@selected && @selected.avatar} 51 + src={@selected.avatar} 52 + alt="" 53 + class="absolute inset-0 size-8 rounded-full border-2 border-ink object-cover" 54 + /> 55 + </span> 56 + 57 + <input 58 + type="text" 59 + name="handle" 60 + id="handle" 61 + value={@form[:handle].value} 62 + placeholder="alice.bsky.social" 63 + phx-debounce="150" 64 + role="combobox" 65 + aria-autocomplete="list" 66 + aria-controls="handle-listbox" 67 + aria-expanded={to_string(@open)} 68 + autocomplete="off" 69 + required 70 + autofocus 71 + autocapitalize="none" 72 + autocorrect="off" 73 + spellcheck="false" 74 + class="w-full rounded-xl border-2 border-ink bg-paper py-3 pl-14 pr-4 placeholder:text-ink/35 focus:outline-none focus:ring-4 focus:ring-sky-bold/40" 75 + /> 76 + 77 + <ul 78 + :if={@open} 79 + id="handle-listbox" 80 + role="listbox" 81 + class="absolute left-0 right-0 top-full z-10 mt-2 max-h-72 overflow-auto rounded-xl border-2 border-ink bg-paper py-1 shadow-[4px_4px_0px_0px_var(--color-ink)]" 82 + > 83 + <li 84 + :for={{actor, index} <- Enum.with_index(@suggestions)} 85 + id={"suggestion-#{index}"} 86 + role="option" 87 + aria-selected="false" 88 + phx-click="select" 89 + phx-value-handle={actor.handle} 90 + class="flex cursor-pointer items-center gap-3 px-3 py-2 hover:bg-ink/5" 91 + > 92 + <img 93 + :if={actor.avatar} 94 + src={actor.avatar} 95 + class="size-8 shrink-0 rounded-full border-2 border-ink object-cover" 96 + /> 97 + <div 98 + :if={!actor.avatar} 99 + class="flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-ink bg-sky-bold/30 text-sm font-bold" 100 + > 101 + {actor.handle |> String.first() |> String.upcase()} 102 + </div> 103 + <div class="min-w-0"> 104 + <p class="truncate text-sm font-bold">{actor.handle}</p> 105 + <p :if={actor.display_name} class="truncate text-xs text-ink/50"> 106 + {actor.display_name} 107 + </p> 108 + </div> 109 + </li> 110 + 111 + <li :if={@suggestions == []} class="px-3 py-2 text-sm text-ink/50"> 112 + No matches. 113 + </li> 114 + </ul> 115 + </div> 116 + </div> 117 + 118 + <button 119 + type="submit" 120 + class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-ink px-5 py-3 font-bold text-paper transition-all hover:scale-[1.01] active:scale-[0.99] [.phx-submit-loading_&]:pointer-events-none [.phx-submit-loading_&]:opacity-70" 121 + > 122 + <span class="hidden items-center gap-2 [.phx-submit-loading_&]:inline-flex"> 123 + <span class="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" /> 124 + Connecting… 125 + </span> 126 + <span class="[.phx-submit-loading_&]:hidden">Continue</span> 127 + </button> 128 + </.form> 129 + 130 + <p class="mt-5 text-center text-xs text-ink/50"> 131 + Any atproto handle works, Bluesky, Eurosky, or your own domain. 132 + </p> 133 + </div> 134 + 135 + <.link href={~p"/"} class="mt-8 text-sm text-ink/55 transition hover:text-ink"> 136 + ← Back to home 137 + </.link> 138 + </div> 139 + 140 + <script :type={Phoenix.LiveView.ColocatedHook} name=".HandleNav"> 141 + export default { 142 + mounted() { 143 + this.active = -1; 144 + this.input = this.el.querySelector("input"); 145 + this.onKey = this.onKey.bind(this); 146 + this.onPick = this.onPick.bind(this); 147 + this.onError = this.onError.bind(this); 148 + this.el.addEventListener("error", this.onError, true); 149 + this.input.addEventListener("keydown", this.onKey); 150 + this.el.addEventListener("click", this.onPick); 151 + }, 152 + 153 + updated() { 154 + this.active = -1; 155 + this.paint(); 156 + }, 157 + 158 + destroyed() { 159 + this.input.removeEventListener("keydown", this.onKey); 160 + this.el.removeEventListener("click", this.onPick); 161 + this.el.removeEventListener("error", this.onError, true); 162 + }, 163 + 164 + options() { 165 + return Array.from(this.el.querySelectorAll('[role="option"]')); 166 + }, 167 + 168 + paint() { 169 + const options = this.options(); 170 + options.forEach((option, index) => { 171 + const isActive = index === this.active; 172 + option.classList.toggle("bg-ink/10", isActive); 173 + option.setAttribute("aria-selected", isActive); 174 + }); 175 + 176 + const current = options[this.active]; 177 + 178 + if (current) { 179 + this.input.setAttribute("aria-activedescendant", current.id); 180 + current.scrollIntoView({block: "nearest"}); 181 + } else { 182 + this.input.removeAttribute("aria-activedescendant"); 183 + } 184 + }, 185 + 186 + move(delta) { 187 + const count = this.options().length; 188 + if (count === 0) return; 189 + this.active = (this.active + delta + count) % count; 190 + this.paint(); 191 + }, 192 + 193 + onKey(e) { 194 + const options = this.options(); 195 + if (e.key === "ArrowDown") { 196 + e.preventDefault(); 197 + this.move(1); 198 + } else if (e.key === "ArrowUp") { 199 + e.preventDefault(); 200 + this.move(-1); 201 + } else if (e.key === "Enter" && this.active >= 0 && options[this.active]) { 202 + e.preventDefault(); 203 + options[this.active].click(); 204 + } else if (e.key === "Escape" && options.length > 0) { 205 + e.preventDefault(); 206 + this.pushEvent("close"); 207 + } 208 + }, 209 + 210 + onPick(e) { 211 + const option = e.target.closest('[role="option"]'); 212 + if (option) this.input.value = option.getAttribute("phx-value-handle"); 213 + }, 214 + 215 + onError(e) { 216 + if (e.target.tagName === "IMG") e.target.remove(); 217 + }, 218 + } 219 + </script> 220 + </Layouts.app> 221 + """ 222 + end 223 + 224 + @impl Phoenix.LiveView 225 + def mount(_params, _session, socket) do 226 + {:ok, 227 + assign(socket, 228 + form: to_form(%{"handle" => ""}), 229 + suggestions: [], 230 + open: false, 231 + selected: nil 232 + )} 233 + end 234 + 235 + @impl Phoenix.LiveView 236 + def handle_event("suggest", %{"handle" => raw}, socket) do 237 + query = 238 + raw 239 + |> String.trim() 240 + |> String.trim_leading("@") 241 + 242 + {open, suggestions} = 243 + if String.length(query) >= @min_query_length do 244 + {true, Directory.search_handles(query)} 245 + else 246 + {false, []} 247 + end 248 + 249 + {:noreply, 250 + assign(socket, 251 + form: to_form(%{"handle" => raw}), 252 + suggestions: suggestions, 253 + open: open, 254 + selected: resolve_selected(suggestions, raw) 255 + )} 256 + end 257 + 258 + def handle_event("select", %{"handle" => handle}, socket) do 259 + {:noreply, 260 + assign(socket, 261 + form: to_form(%{"handle" => handle}), 262 + open: false, 263 + selected: resolve_selected(socket.assigns.suggestions, handle) 264 + )} 265 + end 266 + 267 + def handle_event("close", _params, socket) do 268 + {:noreply, assign(socket, open: false)} 269 + end 270 + 271 + def handle_event("login", %{"handle" => handle}, socket) do 272 + case Login.start_login(handle) do 273 + {:ok, url} -> 274 + {:noreply, redirect(socket, external: url)} 275 + 276 + {:error, reason} -> 277 + socket = 278 + socket 279 + |> put_flash(:error, error_message(reason)) 280 + |> assign(form: to_form(%{"handle" => handle}), open: false) 281 + 282 + {:noreply, socket} 283 + end 284 + end 285 + 286 + defp resolve_selected(suggestions, raw) do 287 + handle = 288 + raw 289 + |> String.trim() 290 + |> String.trim_leading("@") 291 + |> String.downcase() 292 + 293 + Enum.find(suggestions, fn suggestion -> String.downcase(suggestion.handle) == handle end) 294 + end 295 + 296 + defp error_message(:invalid_handle), do: "That doesn't look like a valid handle." 297 + defp error_message(:login_failed), do: "Authorization was denied or failed." 298 + end
+3 -2
lib/annot_at_web/router.ex
··· 22 22 23 23 get "/", PageController, :home 24 24 25 - get "/login", AuthController, :new 26 - post "/login", AuthController, :create 25 + live_session :login, on_mount: [{AnnotAtWeb.UserAuth, :mount_current_scope}] do 26 + live "/login", LoginLive 27 + end 27 28 end 28 29 29 30 scope "/", AnnotAtWeb do
+17 -38
test/annot_at_web/controllers/auth_controller_test.exs
··· 7 7 8 8 @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 9 9 10 - defp create_user do 11 - {:ok, user} = 12 - Accounts.upsert_login( 13 - %{did: @did, handle: "alice.test", pds_host: "https://pds.example.com"}, 14 - %{ 15 - auth_server_issuer: "https://bsky.social", 16 - granted_scopes: "atproto", 17 - access_token: "a", 18 - refresh_token: "r", 19 - dpop_private_jwk: "{}", 20 - expires_at: ~U[2026-01-01 01:00:00Z] 21 - } 22 - ) 23 - 24 - user 25 - end 26 - 27 10 test "GET /oauth-client-metadata.json serves the client metadata", %{conn: conn} do 28 11 conn = get(conn, ~p"/oauth-client-metadata.json") 29 12 metadata = json_response(conn, 200) ··· 38 21 refute Map.has_key?(key, "d") 39 22 end 40 23 41 - test "GET /login renders the form", %{conn: conn} do 42 - conn = get(conn, ~p"/login") 43 - assert html_response(conn, 200) =~ "login-form" 44 - end 45 - 46 - test "POST /login redirects to the authorization URL", %{conn: conn} do 47 - expect(Login, :start_login, fn "alice.test" -> 48 - {:ok, "https://bsky.social/oauth/authorize?x=1"} 49 - end) 50 - 51 - conn = post(conn, ~p"/login", %{"handle" => "alice.test"}) 52 - assert "https://bsky.social/oauth/authorize?x=1" == redirected_to(conn) 53 - end 54 - 55 - test "POST /login re-renders with an error for an invalid handle", %{conn: conn} do 56 - expect(Login, :start_login, fn _ -> {:error, :invalid_handle} end) 57 - 58 - conn = post(conn, ~p"/login", %{"handle" => "nope"}) 59 - assert html_response(conn, 200) =~ "valid handle" 60 - end 61 - 62 24 test "GET /auth/callback logs in and redirects dashboard", %{conn: conn} do 63 25 user = create_user() 64 26 expect(Login, :complete_login, fn _ -> {:ok, user} end) ··· 86 48 87 49 assert ~p"/" == redirected_to(conn) 88 50 refute get_session(conn, :user_id) 51 + end 52 + 53 + defp create_user do 54 + {:ok, user} = 55 + Accounts.upsert_login( 56 + %{did: @did, handle: "alice.test", pds_host: "https://pds.example.com"}, 57 + %{ 58 + auth_server_issuer: "https://bsky.social", 59 + granted_scopes: "atproto", 60 + access_token: "a", 61 + refresh_token: "r", 62 + dpop_private_jwk: "{}", 63 + expires_at: ~U[2026-01-01 01:00:00Z] 64 + } 65 + ) 66 + 67 + user 89 68 end 90 69 end
+83
test/annot_at_web/live/login_live_test.exs
··· 1 + defmodule AnnotAtWeb.LoginLiveTest do 2 + use AnnotAtWeb.ConnCase, async: true 3 + use Mimic 4 + 5 + import Phoenix.LiveViewTest 6 + 7 + alias AnnotAt.Atproto.Directory 8 + alias AnnotAt.Atproto.OAuth.Login 9 + 10 + test "renders the sign-in form", %{conn: conn} do 11 + {:ok, lv, _html} = live(conn, ~p"/login") 12 + 13 + assert has_element?(lv, "input#handle") 14 + assert has_element?(lv, "button[type=submit]") 15 + end 16 + 17 + test "submitting a valid handle redirects to the authorization URL", %{conn: conn} do 18 + authorization_url = "https://bsky.social/oauth/authorize?x=1" 19 + 20 + expect(Login, :start_login, fn "jola.dev" -> 21 + {:ok, authorization_url} 22 + end) 23 + 24 + {:ok, lv, _html} = live(conn, ~p"/login") 25 + 26 + lv 27 + |> form("form", %{"handle" => "jola.dev"}) 28 + |> render_submit() 29 + 30 + assert_redirect(lv, authorization_url) 31 + end 32 + 33 + test "submitting an invalid handle shows an error", %{conn: conn} do 34 + expect(Login, :start_login, fn _ -> 35 + {:error, :invalid_handle} 36 + end) 37 + 38 + {:ok, lv, _html} = live(conn, ~p"/login") 39 + 40 + html = 41 + lv 42 + |> form("form", %{"handle" => "nope"}) 43 + |> render_submit() 44 + 45 + assert html =~ "That doesn&#39;t look like a valid handle" 46 + end 47 + 48 + test "typing suggests matching handles", %{conn: conn} do 49 + expect(Directory, :search_handles, fn "jola" -> 50 + [%{handle: "jola.dev", display_name: "Johanna", avatar: nil}] 51 + end) 52 + 53 + {:ok, lv, _html} = live(conn, ~p"/login") 54 + 55 + html = 56 + lv 57 + |> form("form", %{"handle" => "jola"}) 58 + |> render_change() 59 + 60 + assert html =~ "jola.dev" 61 + end 62 + 63 + test "selecting a suggestion shows matched profile avatar", %{conn: conn} do 64 + avatar = "https://cdn.example/jola.jpg" 65 + 66 + expect(Directory, :search_handles, fn "jola" -> 67 + [%{handle: "jola.dev", display_name: "Johanna", avatar: avatar}] 68 + end) 69 + 70 + {:ok, lv, _html} = live(conn, ~p"/login") 71 + 72 + lv 73 + |> form("form", %{"handle" => "jola"}) 74 + |> render_change() 75 + 76 + lv 77 + |> element("#suggestion-0") 78 + |> render_click() 79 + 80 + refute has_element?(lv, "#handle-listbox") 81 + assert has_element?(lv, "img[src='#{avatar}']") 82 + end 83 + end
+1
test/test_helper.exs
··· 1 1 Mimic.copy(AnnotAt.Atproto.DNS) 2 2 Mimic.copy(AnnotAt.Feeds.Client) 3 + Mimic.copy(AnnotAt.Atproto.Directory) 3 4 Mimic.copy(AnnotAt.Atproto.HTTP) 4 5 Mimic.copy(AnnotAt.Atproto.Identity) 5 6 Mimic.copy(AnnotAt.Atproto.Profile)