From 1b826eea543d5210d9004c8b418d41285238f5b4 Mon Sep 17 00:00:00 2001 From: floatingghost Date: Sun, 4 Sep 2022 23:31:41 +0000 Subject: [PATCH] Allow reacting with remote emoji when they exist on the post (#200) Co-authored-by: FloatingGhost Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/200 --- lib/pleroma/web/activity_pub/builder.ex | 97 +++++++++++++----- .../emoji_react_validator.ex | 2 +- lib/pleroma/web/activity_pub/utils.ex | 32 ++++-- lib/pleroma/web/common_api.ex | 3 +- .../web/mastodon_api/views/status_view.ex | 2 +- .../controllers/emoji_reaction_controller.ex | 10 +- .../pleroma_api/views/emoji_reaction_view.ex | 14 ++- .../emoji_reaction_controller_test.exs | 98 +++++++++++++++++-- 8 files changed, 211 insertions(+), 47 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 97ceaf08e..71ccbdef5 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -55,37 +55,84 @@ def follow(follower, followed) 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} diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index 306a57a93..6109a0355 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -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 diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index b920e8c1d..008aec475 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -329,7 +329,7 @@ def add_emoji_reaction_to_object( 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 @@ def add_emoji_reaction_to_object( 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 @@ def remove_emoji_reaction_from_object( %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 @@ def fetch_latest_undo(%User{ap_id: ap_id}) 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 """ diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index bc5e26cf7..23d353dc2 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -209,7 +209,8 @@ def react_with_emoji(id, user, emoji) 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 diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index d838c4673..0d2571ab8 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -587,7 +587,7 @@ defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_ 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), diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex index 91658587a..0933363a6 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -74,7 +74,10 @@ defp filter(reactions, %{emoji: emoji}) when is_binary(emoji) 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 @@ def create(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) d 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) diff --git a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex index 9993480db..4335228b6 100644 --- a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex +++ b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex @@ -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 @@ def render("show.json", %{emoji_reaction: {emoji, user_ap_ids, url}, user: user} 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), diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index 4898179e6..6864b37cb 100644 --- a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -17,22 +17,29 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} 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 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) ObanHelpers.perform_all() + # Reacting with a custom emoji result = conn @@ -51,7 +59,6 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} 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 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} 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 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} 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 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} 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