diff --git a/assets/css/clacks.scss b/assets/css/clacks.scss index 7f43d19..3b71145 100644 --- a/assets/css/clacks.scss +++ b/assets/css/clacks.scss @@ -35,7 +35,7 @@ input, button:not(.btn-link) { height: 1.75rem; } -button[type=submit]:not(.btn-link) { +button:not(.btn-link) { padding: 0 2rem; font-family: $sans-serif; background-color: $tint-color; @@ -186,3 +186,7 @@ ul.status-list { } } } + +.actor-actions { + margin-bottom: 1rem; +} diff --git a/lib/clacks/activitypub.ex b/lib/clacks/activitypub.ex index 1321790..88a35cf 100644 --- a/lib/clacks/activitypub.ex +++ b/lib/clacks/activitypub.ex @@ -110,6 +110,28 @@ defmodule Clacks.ActivityPub do } end + @spec follow( + actor :: String.t(), + followee :: String.t(), + published :: NaiveDateTime.t(), + state :: String.t() + ) :: + map() + def follow(actor, followee, published \\ DateTime.utc_now(), state \\ "pending") do + %{ + "@context" => @context, + "type" => "Follow", + "id" => activity_id(Ecto.UUID.generate()), + "actor" => actor, + "object" => followee, + "context" => context_id(Ecto.UUID.generate()), + "to" => [followee], + "cc" => [@public], + "published" => published |> DateTime.to_iso8601(), + "state" => state + } + end + @spec object_id(id :: String.t()) :: String.t() def object_id(id) do url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url] diff --git a/lib/clacks/activitypub/helper.ex b/lib/clacks/activitypub/helper.ex new file mode 100644 index 0000000..6b11936 --- /dev/null +++ b/lib/clacks/activitypub/helper.ex @@ -0,0 +1,30 @@ +defmodule Clacks.ActivityPub.Helper do + alias Clacks.{Activity, Actor, Repo} + require Logger + + @spec save_and_federate(activity_data :: map(), actor :: Actor.t()) :: + {:ok, Activity.t()} | :error + def save_and_federate(activity_data, actor) do + changeset = Activity.changeset_for_creating(activity_data, true) + + case Repo.insert(changeset) do + {:error, changeset} -> + Logger.error("Couldn't save activity: #{inspect(changeset)}") + :error + + {:ok, activity} -> + worker = + %{id: activity.id, actor_id: actor.id} + |> Clacks.Worker.Federate.new() + + case Oban.insert(worker) do + {:ok, _} -> + {:ok, activity} + + {:error, changeset} -> + Logger.error("Couldn't save federate job: #{inspect(changeset)}") + :error + end + end + end +end diff --git a/lib/clacks/inbox.ex b/lib/clacks/inbox.ex index b9b5a00..25e788d 100644 --- a/lib/clacks/inbox.ex +++ b/lib/clacks/inbox.ex @@ -2,15 +2,16 @@ defmodule Clacks.Inbox do require Logger alias Clacks.{Repo, Activity, Object, Actor, ActivityPub} - defp store_activity(%{"actor" => actor} = activity, local \\ false) when is_binary(actor) do + defp store_activity(%{"actor" => actor, "id" => ap_id} = activity, local \\ false) + when is_binary(actor) do changeset = - Activity.changeset(%Activity{}, %{ + Activity.changeset(Activity.get_cached_by_ap_id(ap_id) || %Activity{}, %{ data: activity, local: local, actor: actor }) - Repo.insert(changeset) + Repo.insert_or_update(changeset) end @spec handle(activity :: map()) :: :ok | {:error, reason :: any()} @@ -47,8 +48,8 @@ defmodule Clacks.Inbox do case Repo.update(changeset) do {:error, changeset} -> - Logger.error("Couldn't store Follow activity: #{inspect(changeset)}") - {:error, "Couldn't store Follow activity"} + Logger.error("Couldn't store updated followers: #{inspect(changeset)}") + {:error, "Couldn't store updated followers"} {:ok, _followed} -> accept = ActivityPub.accept_follow(activity) @@ -64,6 +65,45 @@ defmodule Clacks.Inbox do end end + def handle( + %{ + "type" => "Accept", + "actor" => followee_id, + "object" => %{"type" => "Follow", "id" => follow_activity_id, "actor" => follower_id} + } = activity + ) do + followee = Actor.get_by_ap_id(followee_id) + follower = Actor.get_by_ap_id(follower_id) + + store_activity(activity) + + follow_activity = Activity.get_cached_by_ap_id(follow_activity_id) + + changeset = + Activity.changeset(follow_activity, %{ + data: %{follow_activity.data | "state" => "accepted"} + }) + + case Repo.update(changeset) do + {:error, changeset} -> + Logger.error("Couldn't store updated Follow activity: #{inspect(changeset)}") + {:error, "Couldn't store updated Follow activity"} + + {:ok, _follow_activity} -> + new_followers = [follower_id | followee.followers] |> Enum.uniq() + changeset = Actor.changeset(followee, %{followers: new_followers}) + + case Repo.update(changeset) do + {:error, changeset} -> + Logger.error("Couldn't store updated followers: #{inspect(changeset)}") + {:error, "Couldn't store updated followers"} + + {:ok, _followee} -> + :ok + end + end + end + # as a fallback, just store the activity def handle(activity) do case store_activity(activity) do diff --git a/lib/clacks_web/controllers/frontend_controller.ex b/lib/clacks_web/controllers/frontend_controller.ex index 4db431d..fd8c1db 100644 --- a/lib/clacks_web/controllers/frontend_controller.ex +++ b/lib/clacks_web/controllers/frontend_controller.ex @@ -3,6 +3,7 @@ defmodule ClacksWeb.FrontendController do alias Clacks.{Actor, User, Timeline, Repo, ActivityPub, Activity, Object} alias ClacksWeb.Router.Helpers, as: Routes alias ClacksWeb.Endpoint + import Ecto.Query @public "https://www.w3.org/ns/activitystreams#Public" @@ -129,7 +130,8 @@ defmodule ClacksWeb.FrontendController do render(conn, "profile.html", %{ current_user: current_user, actor: user.actor, - statuses: actor_statuses(user.actor, params, only_public: true) + statuses: actor_statuses(user.actor, params, only_public: true), + following_state: following_state(current_user.actor, user.actor) }) end end @@ -145,11 +147,33 @@ defmodule ClacksWeb.FrontendController do render(conn, "profile.html", %{ current_user: current_user, actor: actor, - statuses: actor_statuses(actor, params, only_public: true) + statuses: actor_statuses(actor, params, only_public: true), + following_state: following_state(current_user.actor, actor) }) end end + def follow(conn, %{"id" => id}) do + current_user = conn.assigns[:user] |> Repo.preload(:actor) + + case Repo.get(Actor, id) do + nil -> + resp(conn, 404, "Not Found") + + followee -> + if current_user.actor.ap_id in followee.followers do + redirect(conn, to: ClacksWeb.FrontendView.local_actor_link(followee)) + else + follow = ActivityPub.follow(current_user.actor.ap_id, followee.ap_id) + ActivityPub.Helper.save_and_federate(follow, current_user.actor) + + conn + |> put_flash(:info, "Follow request sent") + |> redirect(to: ClacksWeb.FrontendView.local_actor_link(followee)) + end + end + end + def search(conn, %{"q" => q}) when is_binary(q) do current_user = conn.assigns[:user] @@ -201,12 +225,7 @@ defmodule ClacksWeb.FrontendController do {:ok, object} = Repo.insert(note_changeset) create = ActivityPub.create(note) - create_changeset = Activity.changeset_for_creating(create, true) - {:ok, activity} = Repo.insert(create_changeset) - - %{id: activity.id, actor_id: current_user.actor.id} - |> Clacks.Worker.Federate.new() - |> Oban.insert() + {:ok, activity} = ActivityPub.Helper.save_and_federate(create, current_user.actor) path = Map.get(params, "continue", Routes.frontend_path(Endpoint, :status, activity.id)) redirect(conn, to: path) @@ -238,4 +257,24 @@ defmodule ClacksWeb.FrontendController do defp note_for_posting(current_user, %{"content" => content}) do ActivityPub.note(current_user.actor.ap_id, content) end + + @spec following_state(follower :: Actor.t(), followee :: Actor.t()) :: boolean() + defp following_state(follower, followee) do + query = + Activity + |> where([a], fragment("?->>'type'", a.data) == "Follow") + |> where([a], fragment("?->>'actor'", a.data) == ^follower.ap_id) + |> where([a], fragment("?->>'object'", a.data) == ^followee.ap_id) + + case Repo.one(query) do + nil -> + :not_following + + %Activity{data: %{"state" => "pending"}} -> + :pending + + %Activity{data: %{"state" => "accepted"}} -> + :following + end + end end diff --git a/lib/clacks_web/router.ex b/lib/clacks_web/router.ex index 4dc04ac..c3d3f18 100644 --- a/lib/clacks_web/router.ex +++ b/lib/clacks_web/router.ex @@ -61,6 +61,8 @@ defmodule ClacksWeb.Router do get "/status/:id/reply", FrontendController, :reply get "/search", FrontendController, :search get "/actors/:id", FrontendController, :actor + post "/actors/:id/follow", FrontendController, :follow + post "/actors/:id/unfollow", FrontendController, :unfollow end scope "/", ClacksWeb do diff --git a/lib/clacks_web/templates/frontend/profile.html.eex b/lib/clacks_web/templates/frontend/profile.html.eex index 28a0af3..48d83d8 100644 --- a/lib/clacks_web/templates/frontend/profile.html.eex +++ b/lib/clacks_web/templates/frontend/profile.html.eex @@ -10,4 +10,26 @@

<%= @actor.data["summary"] %>

+
+ <%= unless @current_user.actor.ap_id == @actor.ap_id do %> + + <%= case @following_state do %> + <% :not_following -> %> + <%= form_tag Routes.frontend_path(@conn, :follow, @actor.id), method: :post, class: "follow-form" do %> + <%= submit "Follow" %> + <% end %> + + <% :following -> %> + <%= form_tag Routes.frontend_path(@conn, :unfollow, @actor.id), method: :post, class: "follow-form" do %> + <%= submit "Unfollow" %> + <% end %> + + <% :pending -> %> + + + <% end %> + + <% end %> +
+ <%= render "_timeline.html", conn: @conn, statuses_with_authors: Enum.map(@statuses, &({&1, @actor})) %>