···124124 end
125125126126 @doc """
127127+ Updates an existing `com.pdewey.atjams.share` record via `putRecord`,
128128+ preserving the original `createdAt`. Returns the new cid (record changed).
129129+ """
130130+ def update_share(%{did: did, session_key: session_key}, rkey, %{} = form, %DateTime{} = original_created_at)
131131+ when is_binary(rkey) do
132132+ record =
133133+ %{
134134+ "$type" => @share_collection,
135135+ "songUrl" => form.song_url,
136136+ "createdAt" => DateTime.to_iso8601(original_created_at)
137137+ }
138138+ |> maybe_put("title", form.title)
139139+ |> maybe_put("artist", form.artist)
140140+ |> maybe_put("notes", form.notes)
141141+142142+ with {:ok, client} <- OAuthClient.new(session_key),
143143+ {:ok, %{status: 200, body: body}, _client} <-
144144+ XRPC.post(client, "com.atproto.repo.putRecord",
145145+ json: %{
146146+ repo: did,
147147+ collection: @share_collection,
148148+ rkey: rkey,
149149+ record: record
150150+ }
151151+ ) do
152152+ {:ok,
153153+ %{
154154+ uri: Map.get(body, "uri"),
155155+ cid: Map.get(body, "cid"),
156156+ did: did,
157157+ rkey: rkey,
158158+ song_url: form.song_url,
159159+ title: form.title,
160160+ artist: form.artist,
161161+ notes: form.notes,
162162+ created_at: original_created_at
163163+ }}
164164+ else
165165+ {:ok, %{status: status, body: body}, _client} ->
166166+ Logger.warning("putRecord failed: status=#{status} body=#{inspect(body)}")
167167+ {:error, {:pds_error, status, body}}
168168+169169+ {:error, reason, _client} -> {:error, reason}
170170+ {:error, reason} -> {:error, reason}
171171+ end
172172+ end
173173+174174+ @doc """
127175 Creates a `com.pdewey.atjams.like` record on the user's PDS referencing
128176 the given share (via strongRef: `{uri, cid}`).
129177 """
+16
lib/atjams/feed.ex
···160160 end
161161162162 @doc """
163163+ Clears the enrichment columns on a share so the next `Enrichment.start/1`
164164+ refetches. Call this when a share's `song_url` changes via edit.
165165+ """
166166+ def invalidate_enrichment(uri) when is_binary(uri) do
167167+ case Repo.get(Share, uri) do
168168+ nil ->
169169+ :not_found
170170+171171+ %Share{} = share ->
172172+ share
173173+ |> Share.changeset(%{links: nil, link_source: nil, links_fetched_at: nil})
174174+ |> Repo.update()
175175+ end
176176+ end
177177+178178+ @doc """
163179 Lists shares that should be (re)enriched: never fetched, or fetched more
164180 than `max_age_days` ago. Useful for a periodic catch-up sweep.
165181 """
···8080 {:noreply, AtjamsWeb.ShareActions.delete_share(socket, share_uri)}
8181 end
82828383- def handle_event("edit_share", _params, socket) do
8484- {:noreply, put_flash(socket, :info, "Editing shares isn't built yet — delete and re-share for now.")}
8585- end
8686-8783 ## Helpers
88848985 defp current_user_did(%{assigns: %{current_user: %{did: did}}}), do: did
-4
lib/atjams_web/live/profile_live.ex
···102102 {:noreply, AtjamsWeb.ShareActions.delete_share(socket, share_uri)}
103103 end
104104105105- def handle_event("edit_share", _params, socket) do
106106- {:noreply, put_flash(socket, :info, "Editing shares isn't built yet — delete and re-share for now.")}
107107- end
108108-109105 ## Helpers
110106111107 defp current_user_did(%{assigns: %{current_user: %{did: did}}}), do: did
-4
lib/atjams_web/live/share_live.ex
···7070 {:noreply, AtjamsWeb.ShareActions.delete_share(socket, share_uri)}
7171 end
72727373- def handle_event("edit_share", _params, socket) do
7474- {:noreply, put_flash(socket, :info, "Editing shares isn't built yet — delete and re-share for now.")}
7575- end
7676-7773 ## Helpers
78747975 defp current_user_did(%{assigns: %{current_user: %{did: did}}}), do: did
+115-18
lib/atjams_web/live/share_new_live.ex
···11defmodule AtjamsWeb.ShareNewLive do
22 use AtjamsWeb, :live_view
3344- alias Atjams.{Atproto, Enrichment, Feed, MusicLinks}
44+ alias Atjams.{Atproto, Enrichment, Feed}
55 alias Atjams.Feed.ShareForm
6677 @impl true
88- def mount(_params, _session, socket) do
88+ def mount(params, _session, socket) do
99+ socket =
1010+ socket
1111+ |> assign(:submitting?, false)
1212+ |> assign(:looking_up?, false)
1313+ |> assign(:last_lookup_url, nil)
1414+1515+ case socket.assigns.live_action do
1616+ :new -> mount_new(socket)
1717+ :edit -> mount_edit(socket, params)
1818+ end
1919+ end
2020+2121+ defp mount_new(socket) do
922 {:ok,
1023 socket
1124 |> assign(:page_title, "Share a song")
1212- |> assign(:submitting?, false)
1313- |> assign(:looking_up?, false)
1414- |> assign(:last_lookup_url, nil)
2525+ |> assign(:editing_share, nil)
1526 |> assign(:form_params, %{})
1627 |> assign_form(ShareForm.changeset(%{}))}
1728 end
18293030+ defp mount_edit(socket, %{"identifier" => identifier, "rkey" => rkey}) do
3131+ current_did = socket.assigns.current_user.did
3232+3333+ case resolve_did(identifier) do
3434+ {:ok, share_did} when share_did == current_did ->
3535+ case Feed.get_share_by_did_and_rkey(share_did, rkey) do
3636+ %{did: ^current_did} = share ->
3737+ params = %{
3838+ "song_url" => share.song_url,
3939+ "title" => share.title,
4040+ "artist" => share.artist,
4141+ "notes" => share.notes
4242+ }
4343+4444+ changeset = ShareForm.changeset(params) |> Map.put(:action, :validate)
4545+4646+ {:ok,
4747+ socket
4848+ |> assign(:page_title, "Edit share")
4949+ |> assign(:editing_share, share)
5050+ |> assign(:last_lookup_url, share.song_url)
5151+ |> assign(:form_params, params)
5252+ |> assign_form(changeset)}
5353+5454+ _ ->
5555+ {:ok,
5656+ socket
5757+ |> put_flash(:error, "Share not found.")
5858+ |> push_navigate(to: ~p"/feed")}
5959+ end
6060+6161+ {:ok, _other_did} ->
6262+ {:ok,
6363+ socket
6464+ |> put_flash(:error, "You can only edit your own shares.")
6565+ |> push_navigate(to: ~p"/feed")}
6666+6767+ :error ->
6868+ {:ok,
6969+ socket
7070+ |> put_flash(:error, "Couldn't find that account.")
7171+ |> push_navigate(to: ~p"/feed")}
7272+ end
7373+ end
7474+7575+ ## Events
7676+1977 @impl true
2078 def handle_event("validate", %{"share_form" => params}, socket) do
2179 changeset = params |> ShareForm.changeset() |> Map.put(:action, :validate)
···3593 case Ecto.Changeset.apply_action(changeset, :insert) do
3694 {:ok, form} ->
3795 socket = assign(socket, :submitting?, true)
3838- do_create(socket, form)
9696+9797+ case socket.assigns.editing_share do
9898+ nil -> do_create(socket, form)
9999+ share -> do_update(socket, form, share)
100100+ end
3910140102 {:error, changeset} ->
41103 {:noreply, assign_form(socket, changeset)}
42104 end
43105 end
106106+107107+ ## Async lookup (autofill)
4410845109 @impl true
46110 def handle_async(:lookup, {:ok, {url, {:ok, payload}}}, socket) do
···59123 {:noreply, assign(socket, :looking_up?, false)}
60124 end
611256262- ## URL lookup orchestration
126126+ ## Helpers — lookup
6312764128 defp maybe_start_lookup(socket, song_url) do
65129 cond do
···73137 socket
74138 |> assign(:last_lookup_url, url)
75139 |> assign(:looking_up?, true)
7676- |> start_async(:lookup, fn -> {url, MusicLinks.fetch_for(url)} end)
140140+ |> start_async(:lookup, fn -> {url, Atjams.MusicLinks.fetch_for(url)} end)
77141 end
7814279143 defp looks_like_url?(url) when is_binary(url) do
···96160 |> assign_form(changeset)
97161 end
981629999- # Only autofill empty fields — never clobber what the user typed.
100163 defp maybe_put(map, key, val) when is_binary(val) and val != "" do
101164 case Map.get(map, key) do
102165 nil -> Map.put(map, key, val)
···107170108171 defp maybe_put(map, _key, _), do: map
109172110110- ## Share creation
173173+ ## Create vs update
111174112175 defp do_create(socket, form) do
113176 case Atproto.create_share(socket.assigns.current_user, form) do
···120183 |> put_flash(:info, "Shared.")
121184 |> push_navigate(to: ~p"/feed")}
122185123123- {:error, {:pds_error, status, body}} ->
124124- msg = pds_error_message(status, body)
186186+ {:error, reason} ->
187187+ {:noreply, error_flash(socket, reason)}
188188+ end
189189+ end
190190+191191+ defp do_update(socket, form, original) do
192192+ case Atproto.update_share(socket.assigns.current_user, original.rkey, form, original.created_at) do
193193+ {:ok, share_attrs} ->
194194+ {:ok, share} = Feed.upsert_share(share_attrs)
195195+196196+ if share_attrs.song_url != original.song_url do
197197+ Feed.invalidate_enrichment(share.uri)
198198+ end
199199+200200+ Enrichment.start(share.uri)
125201126202 {:noreply,
127203 socket
128128- |> assign(:submitting?, false)
129129- |> put_flash(:error, msg)}
204204+ |> put_flash(:info, "Updated.")
205205+ |> push_navigate(to: ~p"/profile/#{share.did}/#{share.rkey}")}
130206131207 {:error, reason} ->
132132- {:noreply,
133133- socket
134134- |> assign(:submitting?, false)
135135- |> put_flash(:error, "Couldn't reach your PDS: #{inspect(reason)}")}
208208+ {:noreply, error_flash(socket, reason)}
136209 end
137210 end
138211212212+ defp error_flash(socket, {:pds_error, status, body}) do
213213+ msg = pds_error_message(status, body)
214214+ socket |> assign(:submitting?, false) |> put_flash(:error, msg)
215215+ end
216216+217217+ defp error_flash(socket, reason) do
218218+ socket
219219+ |> assign(:submitting?, false)
220220+ |> put_flash(:error, "Couldn't reach your PDS: #{inspect(reason)}")
221221+ end
222222+139223 defp pds_error_message(status, %{"message" => msg}) when is_binary(msg) do
140224 "PDS rejected the record (#{status}): #{msg}"
141225 end
···150234151235 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
152236 assign(socket, :form, to_form(changeset, as: "share_form"))
237237+ end
238238+239239+ defp resolve_did("did:" <> _ = did), do: {:ok, did}
240240+241241+ defp resolve_did(handle) when is_binary(handle) do
242242+ case Feed.get_profile_by_handle(handle) do
243243+ %_{did: did} -> {:ok, did}
244244+ nil ->
245245+ case Atproto.resolve_identity(handle) do
246246+ {:ok, %{did: did}} -> {:ok, did}
247247+ _ -> :error
248248+ end
249249+ end
153250 end
154251end
+14-3
lib/atjams_web/live/share_new_live.html.heex
···77 <.icon name="hero-arrow-left" class="size-4" /> Back to feed
88 </.link>
991010- <h1 class="text-2xl font-bold tracking-tight mt-3">Share a song</h1>
1010+ <h1 class="text-2xl font-bold tracking-tight mt-3">
1111+ {if @editing_share, do: "Edit share", else: "Share a song"}
1212+ </h1>
1113 <p class="text-sm text-base-content/60 mt-0.5">
1212- Drop a link from Spotify, Apple Music, YouTube, Bandcamp, or anywhere else.
1414+ <%= if @editing_share do %>
1515+ Update the link or notes. Posted {Calendar.strftime(@editing_share.created_at, "%b %d, %Y")}.
1616+ <% else %>
1717+ Drop a link from Spotify, Apple Music, YouTube, Bandcamp, or anywhere else.
1818+ <% end %>
1319 </p>
1420 </div>
1521···102108 disabled={@submitting?}
103109 class="px-5 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white text-sm font-medium shadow-sm shadow-violet-500/20 hover:brightness-105 disabled:opacity-50 disabled:cursor-not-allowed transition"
104110 >
105105- {if @submitting?, do: "Posting…", else: "Share"}
111111+ {cond do
112112+ @submitting? and @editing_share -> "Saving…"
113113+ @submitting? -> "Posting…"
114114+ @editing_share -> "Save"
115115+ true -> "Share"
116116+ end}
106117 </button>
107118 </div>
108119 </.form>
+1
lib/atjams_web/router.ex
···3333 {AtjamsWeb.UserAuth, :require_session}
3434 ] do
3535 live "/share/new", ShareNewLive, :new
3636+ live "/profile/:identifier/:rkey/edit", ShareNewLive, :edit
3637 end
3738 end
3839
+65-2
nix/default.nix
···11{
22 lib,
33 beam,
44- elixir,
54 nodejs,
55+ stdenv,
66+ zstd,
67 appName ? "atjams",
78}:
891010+let
1111+ version = "0.1.0";
1212+in
913beam.mixRelease {
1014 pname = appName;
1111- version = "0.1.0";
1515+ inherit version;
1216 src = ../.;
1317 mixEnv = "prod";
1418···1620 nodejs
1721 ];
18222323+ # ezstd is a NIF dependency that requires zstd. We provide the system
2424+ # zstd and patch build_deps.sh to be a no-op (it normally clones & builds
2525+ # zstd from source, which doesn't work in the Nix sandbox).
2626+ buildInputs = [
2727+ stdenv.cc
2828+ zstd
2929+ ];
3030+3131+ configurePhase = ''
3232+ runHook preConfigure
3333+ echo "=== CUSTOM configurePhase from atjams ==="
3434+3535+ # Fix ezstd: provide system zstd headers+lib and make build_deps.sh a
3636+ # no-op (it normally clones & builds zstd from source, which doesn't
3737+ # work in the Nix sandbox).
3838+ if [ -d "$MIX_DEPS_PATH/ezstd" ]; then
3939+ echo "ezstd found at $MIX_DEPS_PATH/ezstd"
4040+ ZSTD_DIR="$MIX_DEPS_PATH/ezstd/_build/deps/zstd"
4141+ mkdir -p "$ZSTD_DIR/lib"
4242+4343+ for h in zstd.h zstd_errors.h zdict.h; do
4444+ if [ -f "${zstd.dev}/include/$h" ]; then
4545+ ln -sf "${zstd.dev}/include/$h" "$ZSTD_DIR/lib/"
4646+ fi
4747+ done
4848+4949+ ln -sf "${zstd.out}/lib/libzstd.so" "$ZSTD_DIR/lib/libzstd.so" 2>/dev/null || true
5050+5151+ printf '#!/usr/bin/env bash\nexit 0\n' > "$MIX_DEPS_PATH/ezstd/build_deps.sh"
5252+ chmod +x "$MIX_DEPS_PATH/ezstd/build_deps.sh"
5353+ chmod +x "$MIX_DEPS_PATH/ezstd/c_src/"*.sh 2>/dev/null || true
5454+ else
5555+ echo "ezstd NOT FOUND at $MIX_DEPS_PATH/ezstd"
5656+ echo "MIX_DEPS_PATH=$MIX_DEPS_PATH"
5757+ ls "$MIX_DEPS_PATH/" 2>/dev/null || echo "deps dir empty or missing"
5858+ fi
5959+6060+ mix deps.compile --no-deps-check --skip-umbrella-children
6161+6262+ ln -sfn "$MIX_DEPS_PATH" deps
6363+6464+ runHook postConfigure
6565+ '';
6666+6767+ # Fetch hex dependencies as a fixed-output derivation.
6868+ # To update: delete the hash, rebuild, and copy the suggested hash.
6969+ mixFodDeps = beam.fetchMixDeps {
7070+ pname = appName;
7171+ inherit version;
7272+ src = ../.;
7373+ hash = "sha256-e2YhuIeAlKYVJBwae4ly5WAI+E8tihmjlhADgkRCwfU=";
7474+ };
7575+1976 preBuild = ''
2077 mix assets.deploy
7878+ '';
7979+8080+ postInstall = ''
8181+ chmod +x "$out/bin/atjams"
8282+ chmod +x "$out/bin/server" 2>/dev/null || true
8383+ chmod +x "$out/bin/migrate" 2>/dev/null || true
2184 '';
22852386 meta = with lib; {