Allow reacting with remote emoji when they exist on the post (#200)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Sun, 4 Sep 2022 23:31:41 +0000 (23:31 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Sun, 4 Sep 2022 23:31:41 +0000 (23:31 +0000)
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/200

lib/pleroma/web/activity_pub/builder.ex
lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex
lib/pleroma/web/activity_pub/utils.ex
lib/pleroma/web/common_api.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex
lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex
test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs

index 97ceaf08ecfa8ec008d753948bd0a502903162e4..71ccbdef59a2ddc83acd0693243b06edd5ba9513 100644 (file)
@@ -55,37 +55,84 @@ defmodule Pleroma.Web.ActivityPub.Builder do
     {:ok, data, []}
   end
 
+  defp unicode_emoji_react(_object, data, emoji) do
+    data
+    |> Map.put("content", emoji)
+    |> Map.put("type", "EmojiReact")
+  end
+
+  defp add_emoji_content(data, emoji, url) do
+    data
+    |> Map.put("content", Emoji.maybe_quote(emoji))
+    |> Map.put("type", "EmojiReact")
+    |> Map.put("tag", [
+      %{}
+      |> Map.put("id", url)
+      |> Map.put("type", "Emoji")
+      |> Map.put("name", Emoji.maybe_quote(emoji))
+      |> Map.put(
+        "icon",
+        %{}
+        |> Map.put("type", "Image")
+        |> Map.put("url", url)
+      )
+    ])
+  end
+
+  defp remote_custom_emoji_react(
+         %{data: %{"reactions" => existing_reactions}} = object,
+         data,
+         emoji
+       ) do
+    [emoji_code, instance] = String.split(Emoji.stripped_name(emoji), "@")
+
+    matching_reaction =
+      Enum.find(
+        existing_reactions,
+        fn [name, _, url] ->
+          url = URI.parse(url)
+          url.host == instance && name == emoji_code
+        end
+      )
+
+    if matching_reaction do
+      [name, _, url] = matching_reaction
+      add_emoji_content(data, name, url)
+    else
+      {:error, "Could not react"}
+    end
+  end
+
+  defp remote_custom_emoji_react(_object, data, emoji) do
+    {:error, "Could not react"}
+  end
+
+  defp local_custom_emoji_react(data, emoji) do
+    with %{} = emojo <- Emoji.get(emoji) do
+      path = emojo |> Map.get(:file)
+      url = "#{Endpoint.url()}#{path}"
+      add_emoji_content(data, emojo.code, url)
+    else
+      _ -> {:error, "Emoji does not exist"}
+    end
+  end
+
+  defp custom_emoji_react(object, data, emoji) do
+    if String.contains?(emoji, "@") do
+      remote_custom_emoji_react(object, data, emoji)
+    else
+      local_custom_emoji_react(data, emoji)
+    end
+  end
+
   @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()}
   def emoji_react(actor, object, emoji) do
     with {:ok, data, meta} <- object_action(actor, object) do
       data =
         if Emoji.is_unicode_emoji?(emoji) do
-          data
-          |> Map.put("content", emoji)
-          |> Map.put("type", "EmojiReact")
+          unicode_emoji_react(object, data, emoji)
         else
-          with %{} = emojo <- Emoji.get(emoji) do
-            path = emojo |> Map.get(:file)
-            url = "#{Endpoint.url()}#{path}"
-
-            data
-            |> Map.put("content", emoji)
-            |> Map.put("type", "EmojiReact")
-            |> Map.put("tag", [
-              %{}
-              |> Map.put("id", url)
-              |> Map.put("type", "Emoji")
-              |> Map.put("name", emojo.code)
-              |> Map.put(
-                "icon",
-                %{}
-                |> Map.put("type", "Image")
-                |> Map.put("url", url)
-              )
-            ])
-          else
-            _ -> {:error, "Emoji does not exist"}
-          end
+          custom_emoji_react(object, data, emoji)
         end
 
       {:ok, data, meta}
index 306a57a93bf23740a0ada1501c275ac9e6a86963..6109a0355e93f12a32dafd9ff0877c6e55be3a03 100644 (file)
@@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do
   import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
 
   @primary_key false
-  @emoji_regex ~r/:[A-Za-z0-9_-]+:/
+  @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/
 
   embedded_schema do
     quote do
index b920e8c1d1648d12e847adc81958bb6f2fb10690..008aec475b19761964c100001e7ea657ce349b1f 100644 (file)
@@ -329,7 +329,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
         object
       ) do
     reactions = get_cached_emoji_reactions(object)
-    emoji = stripped_emoji_name(emoji)
+    emoji = Pleroma.Emoji.stripped_name(emoji)
     url = emoji_url(emoji, activity)
 
     new_reactions =
@@ -356,12 +356,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     update_element_in_object("reaction", new_reactions, object, count)
   end
 
-  defp stripped_emoji_name(name) do
-    name
-    |> String.replace_leading(":", "")
-    |> String.replace_trailing(":", "")
-  end
-
   defp emoji_url(
          name,
          %Activity{
@@ -384,7 +378,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
         %Activity{data: %{"content" => emoji, "actor" => actor}} = activity,
         object
       ) do
-    emoji = stripped_emoji_name(emoji)
+    emoji = Pleroma.Emoji.stripped_name(emoji)
     reactions = get_cached_emoji_reactions(object)
     url = emoji_url(emoji, activity)
 
@@ -513,19 +507,37 @@ defmodule Pleroma.Web.ActivityPub.Utils do
 
   def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do
     %{data: %{"object" => object_ap_id}} = Activity.get_by_id(internal_activity_id)
-
     emoji = Pleroma.Emoji.maybe_quote(emoji)
 
     "EmojiReact"
     |> Activity.Queries.by_type()
     |> where(actor: ^ap_id)
-    |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+    |> custom_emoji_discriminator(emoji)
     |> Activity.Queries.by_object_id(object_ap_id)
     |> order_by([activity], fragment("? desc nulls last", activity.id))
     |> limit(1)
     |> Repo.one()
   end
 
+  defp custom_emoji_discriminator(query, emoji) do
+    if String.contains?(emoji, "@") do
+      stripped = Pleroma.Emoji.stripped_name(emoji)
+      [name, domain] = String.split(stripped, "@")
+      domain_pattern = "%" <> domain <> "%"
+      emoji_pattern = Pleroma.Emoji.maybe_quote(name)
+
+      query
+      |> where([activity], fragment("?->>'content' = ?
+        AND EXISTS (
+          SELECT FROM jsonb_array_elements(?->'tag') elem
+          WHERE elem->>'id' ILIKE ?
+        )", activity.data, ^emoji_pattern, activity.data, ^domain_pattern))
+    else
+      query
+      |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+    end
+  end
+
   #### Announce-related helpers
 
   @doc """
index bc5e26cf7c68f568734b7b79a48297695c5c58a8..23d353dc226cf843e55aca18754ff569735a42ec 100644 (file)
@@ -209,7 +209,8 @@ defmodule Pleroma.Web.CommonAPI do
          {:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do
       {:ok, activity}
     else
-      _ -> {:error, dgettext("errors", "Could not add reaction emoji")}
+      _ ->
+        {:error, dgettext("errors", "Could not add reaction emoji")}
     end
   end
 
index d838c4673512b1b9d0ba082d3a0c936e5d4d4f6b..0d2571ab8eeb8dc2db98c7b754acac06e11ca8d8 100644 (file)
@@ -587,7 +587,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
   defp build_emoji_map(emoji, users, url, current_user) do
     %{
-      name: emoji,
+      name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
       count: length(users),
       url: MediaProxy.url(url),
       me: !!(current_user && current_user.ap_id in users),
index 91658587ada1feb75f3d2993421acfa4230a9ea4..0933363a688171756aff96d4cceb35fdfaddf651 100644 (file)
@@ -74,7 +74,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
   defp filter(reactions, _), do: reactions
 
   def create(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do
-    emoji = Pleroma.Emoji.maybe_quote(emoji)
+    emoji =
+      emoji
+      |> Pleroma.Emoji.fully_qualify_emoji()
+      |> Pleroma.Emoji.maybe_quote()
 
     with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do
       activity = Activity.get_by_id(activity_id)
@@ -86,6 +89,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
   end
 
   def delete(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do
+    emoji =
+      emoji
+      |> Pleroma.Emoji.fully_qualify_emoji()
+      |> Pleroma.Emoji.maybe_quote()
+
     with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do
       activity = Activity.get_by_id(activity_id)
 
index 9993480dbe224262d92e08f6a28779ca7d73d9a1..4335228b6b10ee00ed0c09438c68921bdeac8775 100644 (file)
@@ -8,6 +8,18 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionView do
   alias Pleroma.Web.MastodonAPI.AccountView
   alias Pleroma.Web.MediaProxy
 
+  def emoji_name(emoji, nil), do: emoji
+
+  def emoji_name(emoji, url) do
+    url = URI.parse(url)
+
+    if url.host == Pleroma.Web.Endpoint.host() do
+      emoji
+    else
+      "#{emoji}@#{url.host}"
+    end
+  end
+
   def render("index.json", %{emoji_reactions: emoji_reactions} = opts) do
     render_many(emoji_reactions, __MODULE__, "show.json", opts)
   end
@@ -16,7 +28,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionView do
     users = fetch_users(user_ap_ids)
 
     %{
-      name: emoji,
+      name: emoji_name(emoji, url),
       count: length(users),
       accounts: render(AccountView, "index.json", users: users, for: user),
       url: MediaProxy.url(url),
index 4898179e64e6d38cd06059a054af1489b3786c54..6864b37cb5d2989e0aa56b39266a67e803bec03a 100644 (file)
@@ -17,22 +17,29 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
     user = insert(:user)
     other_user = insert(:user)
 
-    {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+    note = insert(:note, user: user, data: %{"reactions" => [["👍", [other_user.ap_id], nil]]})
+    activity = insert(:note_activity, note: note, user: user)
 
     result =
       conn
       |> assign(:user, other_user)
       |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
-      |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/")
+      |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/\u26A0")
       |> json_response_and_validate_schema(200)
 
-    # We return the status, but this our implementation detail.
     assert %{"id" => id} = result
     assert to_string(activity.id) == id
 
     assert result["pleroma"]["emoji_reactions"] == [
              %{
-               "name" => "☕",
+               "name" => "👍",
+               "count" => 1,
+               "me" => true,
+               "url" => nil,
+               "account_ids" => [other_user.id]
+             },
+             %{
+               "name" => "\u26A0\uFE0F",
                "count" => 1,
                "me" => true,
                "url" => nil,
@@ -43,6 +50,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
     {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
 
     ObanHelpers.perform_all()
+
     # Reacting with a custom emoji
     result =
       conn
@@ -51,7 +59,6 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
       |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:dinosaur:")
       |> json_response_and_validate_schema(200)
 
-    # We return the status, but this our implementation detail.
     assert %{"id" => id} = result
     assert to_string(activity.id) == id
 
@@ -65,6 +72,46 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
              }
            ]
 
+    # Reacting with a remote emoji
+    note =
+      insert(:note,
+        user: user,
+        data: %{"reactions" => [["wow", [other_user.ap_id], "https://remote/emoji/wow"]]}
+      )
+
+    activity = insert(:note_activity, note: note, user: user)
+
+    result =
+      conn
+      |> assign(:user, user)
+      |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:statuses"]))
+      |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+      |> json_response(200)
+
+    assert result["pleroma"]["emoji_reactions"] == [
+             %{
+               "name" => "wow@remote",
+               "count" => 2,
+               "me" => true,
+               "url" => "https://remote/emoji/wow",
+               "account_ids" => [user.id, other_user.id]
+             }
+           ]
+
+    # Reacting with a remote custom emoji that hasn't been reacted with yet
+    note =
+      insert(:note,
+        user: user
+      )
+
+    activity = insert(:note_activity, note: note, user: user)
+
+    assert conn
+           |> assign(:user, user)
+           |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:statuses"]))
+           |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+           |> json_response(400)
+
     # Reacting with a non-emoji
     assert conn
            |> assign(:user, other_user)
@@ -77,10 +124,22 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
     user = insert(:user)
     other_user = insert(:user)
 
-    {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+    note =
+      insert(:note,
+        user: user,
+        data: %{"reactions" => [["wow", [user.ap_id], "https://remote/emoji/wow"]]}
+      )
+
+    activity = insert(:note_activity, note: note, user: user)
+
+    ObanHelpers.perform_all()
+
     {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
     {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, ":dinosaur:")
 
+    {:ok, _reaction_activity} =
+      CommonAPI.react_with_emoji(activity.id, other_user, ":wow@remote:")
+
     ObanHelpers.perform_all()
 
     result =
@@ -107,7 +166,32 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
 
     object = Object.get_by_ap_id(activity.data["object"])
 
-    assert object.data["reaction_count"] == 0
+    assert object.data["reaction_count"] == 2
+
+    # Remove custom remote emoji
+    result =
+      conn
+      |> assign(:user, other_user)
+      |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+      |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+      |> json_response(200)
+
+    assert result["pleroma"]["emoji_reactions"] == [
+             %{
+               "name" => "wow@remote",
+               "count" => 1,
+               "me" => false,
+               "url" => "https://remote/emoji/wow",
+               "account_ids" => [user.id]
+             }
+           ]
+
+    # Remove custom remote emoji that hasn't been reacted with yet
+    assert conn
+           |> assign(:user, other_user)
+           |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+           |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/:zoop@remote:")
+           |> json_response(400)
   end
 
   test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do