From 4b85d1c617e0d3d482dffe59e43c69d717d5a13f Mon Sep 17 00:00:00 2001 From: Alexander Tumin Date: Sun, 18 Dec 2022 21:52:19 +0300 Subject: [PATCH] Allow custom emoji reactions: Fix tests, mixed custom and unicode reactions --- lib/pleroma/emoji.ex | 54 +++++++++++++++++ lib/pleroma/web/activity_pub/builder.ex | 6 +- .../object_validators/common_fields.ex | 7 +++ .../mastodon_api/views/notification_view.ex | 5 +- test/fixtures/custom-emoji-reaction.json | 28 +++++++++ .../emoji_react_handling_test.exs | 60 ++++++++++++++++++- .../web/activity_pub/side_effects_test.exs | 2 +- .../emoji_react_handling_test.exs | 53 +++++++++++++++- .../views/notification_view_test.exs | 3 +- .../mastodon_api/views/status_view_test.exs | 42 +++++++++---- 10 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 test/fixtures/custom-emoji-reaction.json diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index dd65d56ae..749b4f4ca 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -51,6 +51,15 @@ def reload do @doc "Returns the path of the emoji `name`." @spec get(String.t()) :: String.t() | nil def get(name) do + name = + if String.starts_with?(name, ":") do + name + |> String.replace_leading(":", "") + |> String.replace_trailing(":", "") + else + name + end + case :ets.lookup(@ets, name) do [{_, path}] -> path _ -> nil @@ -139,6 +148,51 @@ def is_unicode_emoji?(unquote(emoji)), do: true def is_unicode_emoji?(_), do: false + def stripped_name(name) when is_binary(name) do + name + |> String.replace_leading(":", "") + |> String.replace_trailing(":", "") + end + + def stripped_name(name), do: name + + def maybe_quote(name) when is_binary(name) do + if is_unicode_emoji?(name) do + name + else + if String.starts_with?(name, ":") do + name + else + ":#{name}:" + end + end + end + + def maybe_quote(name), do: name + + def emoji_url(%{"type" => "EmojiReact", "content" => _, "tag" => []}), do: nil + + def emoji_url(%{"type" => "EmojiReact", "content" => emoji, "tag" => tags}) do + tag = + tags + |> Enum.find(fn tag -> tag["type"] == "Emoji" && tag["name"] == stripped_name(emoji) end) + + if is_nil(tag) do + nil + else + tag + |> Map.get("icon") + |> Map.get("url") + end + end + + def emoji_url(_), do: nil + + def emoji_name_with_instance(name, url) do + url = url |> URI.parse() |> Map.get(:host) + "#{name}@#{url}" + end + emoji_qualification_map = emojis |> Enum.filter(&String.contains?(&1, "\uFE0F")) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 4681b3dc9..9553bde23 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -90,8 +90,10 @@ defp remote_custom_emoji_react( Enum.find( existing_reactions, fn [name, _, url] -> - url = URI.parse(url) - url.host == instance && name == emoji_code + if url != nil do + url = URI.parse(url) + url.host == instance && name == emoji_code + end end ) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex index 7b60c139a..904fdeced 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex @@ -58,10 +58,17 @@ defmacro status_object_fields do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inReplyTo, ObjectValidators.ObjectID) + field(:quoteUri, ObjectValidators.ObjectID) field(:url, ObjectValidators.Uri) field(:likes, {:array, ObjectValidators.ObjectID}, default: []) field(:announcements, {:array, ObjectValidators.ObjectID}, default: []) end end + + defmacro tag_fields do + quote bind_quoted: binding() do + embeds_many(:tag, TagValidator) + end + end end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index b5b5b2376..314246d3e 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MediaProxy alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView @@ -145,7 +146,9 @@ defp put_report(response, activity) do end defp put_emoji(response, activity) do - Map.put(response, :emoji, activity.data["content"]) + response + |> Map.put(:emoji, activity.data["content"]) + |> Map.put(:emoji_url, MediaProxy.url(Pleroma.Emoji.emoji_url(activity.data))) end defp put_chat_message(response, activity, reading_user, opts) do diff --git a/test/fixtures/custom-emoji-reaction.json b/test/fixtures/custom-emoji-reaction.json new file mode 100644 index 000000000..003de0511 --- /dev/null +++ b/test/fixtures/custom-emoji-reaction.json @@ -0,0 +1,28 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "Hashtag": "as:Hashtag" + } + ], + "type": "Like", + "id": "https://misskey.local.live/likes/917ocsybgp", + "actor": "https://misskey.local.live/users/8x8yep20u2", + "object": "https://pleroma.local.live/objects/89937a53-2692-4631-bb62-770091267391", + "content": ":hanapog:", + "_misskey_reaction": ":hanapog:", + "tag": [ + { + "id": "https://misskey.local.live/emojis/hanapog", + "type": "Emoji", + "name": ":hanapog:", + "updated": "2022-06-07T12:00:05.773Z", + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": "https://misskey.local.live/files/webpublic-8f8a9768-7264-4171-88d6-2356aabeadcd" + } + } + ] +} diff --git a/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs index bbdb09c4c..9bb291a38 100644 --- a/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs @@ -38,16 +38,70 @@ test "it is not valid without a 'content' field", %{valid_emoji_react: valid_emo assert {:content, {"can't be blank", [validation: :required]}} in cng.errors end - test "it is not valid with a non-emoji content field", %{valid_emoji_react: valid_emoji_react} do + test "it is valid when custom emoji is used", %{valid_emoji_react: valid_emoji_react} do without_emoji_content = valid_emoji_react - |> Map.put("content", "x") + |> Map.put("content", ":hello:") + |> Map.put("tag", [ + %{ + "type" => "Emoji", + "name" => ":hello:", + "icon" => %{"url" => "http://somewhere", "type" => "Image"} + } + ]) + + {:ok, _, _} = ObjectValidator.validate(without_emoji_content, []) + end + + test "it is not valid when custom emoji don't have a matching tag", %{ + valid_emoji_react: valid_emoji_react + } do + without_emoji_content = + valid_emoji_react + |> Map.put("content", ":hello:") + |> Map.put("tag", [ + %{ + "type" => "Emoji", + "name" => ":whoops:", + "icon" => %{"url" => "http://somewhere", "type" => "Image"} + } + ]) {:error, cng} = ObjectValidator.validate(without_emoji_content, []) refute cng.valid? - assert {:content, {"must be a single character emoji", []}} in cng.errors + assert {:tag, {"does not contain an Emoji tag", []}} in cng.errors + end + + test "it is not valid when custom emoji have no tags", %{ + valid_emoji_react: valid_emoji_react + } do + without_emoji_content = + valid_emoji_react + |> Map.put("content", ":hello:") + |> Map.put("tag", []) + + {:error, cng} = ObjectValidator.validate(without_emoji_content, []) + + refute cng.valid? + + assert {:tag, {"does not contain an Emoji tag", []}} in cng.errors + end + + test "it is not valid when custom emoji doesn't match a shortcode format", %{ + valid_emoji_react: valid_emoji_react + } do + without_emoji_content = + valid_emoji_react + |> Map.put("content", "hello") + |> Map.put("tag", []) + + {:error, cng} = ObjectValidator.validate(without_emoji_content, []) + + refute cng.valid? + + assert {:tag, {"does not contain an Emoji tag", []}} in cng.errors end end end diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index b24831e85..6820e23d0 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -453,7 +453,7 @@ test "adds the reaction to the object", %{emoji_react: emoji_react, user: user} object = Object.get_by_ap_id(emoji_react.data["object"]) assert object.data["reaction_count"] == 1 - assert ["👌", [user.ap_id]] in object.data["reactions"] + assert ["👌", [user.ap_id], nil] in object.data["reactions"] end test "creates a notification", %{emoji_react: emoji_react, poster: poster} do diff --git a/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs index 83bf59c6f..f2e1cefa3 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs @@ -34,7 +34,56 @@ test "it works for incoming emoji reactions" do object = Object.get_by_ap_id(data["object"]) assert object.data["reaction_count"] == 1 - assert match?([["👌", _]], object.data["reactions"]) + assert match?([["👌", _, nil]], object.data["reactions"]) + end + + test "it works for incoming custom emoji reactions" do + user = insert(:user) + other_user = insert(:user, local: false) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) + + data = + File.read!("test/fixtures/custom-emoji-reaction.json") + |> Jason.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("actor", other_user.ap_id) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == other_user.ap_id + assert data["type"] == "EmojiReact" + assert data["id"] == "https://misskey.local.live/likes/917ocsybgp" + assert data["object"] == activity.data["object"] + assert data["content"] == ":hanapog:" + + assert data["tag"] == [ + %{ + "id" => "https://misskey.local.live/emojis/hanapog", + "type" => "Emoji", + "name" => "hanapog", + "updated" => "2022-06-07T12:00:05.773Z", + "icon" => %{ + "type" => "Image", + "url" => + "https://misskey.local.live/files/webpublic-8f8a9768-7264-4171-88d6-2356aabeadcd" + } + } + ] + + object = Object.get_by_ap_id(data["object"]) + + assert object.data["reaction_count"] == 1 + + assert match?( + [ + [ + "hanapog", + _, + "https://misskey.local.live/files/webpublic-8f8a9768-7264-4171-88d6-2356aabeadcd" + ] + ], + object.data["reactions"] + ) end test "it works for incoming unqualified emoji reactions" do @@ -65,7 +114,7 @@ test "it works for incoming unqualified emoji reactions" do object = Object.get_by_ap_id(data["object"]) assert object.data["reaction_count"] == 1 - assert match?([[^emoji, _]], object.data["reactions"]) + assert match?([[^emoji, _, _]], object.data["reactions"]) end test "it reject invalid emoji reactions" do diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index 6ea894691..e52985f8d 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -190,7 +190,8 @@ test "EmojiReact notification" do emoji: "☕", account: AccountView.render("show.json", %{user: other_user, for: user}), status: StatusView.render("show.json", %{activity: activity, for: user}), - created_at: Utils.to_masto_date(notification.inserted_at) + created_at: Utils.to_masto_date(notification.inserted_at), + emoji_url: nil } test_notifications_rendering([notification], user, [expected]) diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index f76b115b7..a1f3e3e1d 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -35,16 +35,26 @@ test "has an emoji reaction list" do {:ok, activity} = CommonAPI.post(user, %{status: "dae cofe??"}) {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "☕") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, ":dinosaur:") {:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "🍵") {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, ":dinosaur:") + activity = Repo.get(Activity, activity.id) status = StatusView.render("show.json", activity: activity) assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 2, me: false}, - %{name: "🍵", count: 1, me: false} + %{name: "☕", count: 2, me: false, url: nil, account_ids: [other_user.id, user.id]}, + %{ + count: 2, + me: false, + name: "dinosaur", + url: "http://localhost:4001/emoji/dino walking.gif", + account_ids: [other_user.id, user.id] + }, + %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]} ] status = StatusView.render("show.json", activity: activity, for: user) @@ -52,8 +62,15 @@ test "has an emoji reaction list" do assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 2, me: true}, - %{name: "🍵", count: 1, me: false} + %{name: "☕", count: 2, me: true, url: nil, account_ids: [other_user.id, user.id]}, + %{ + count: 2, + me: true, + name: "dinosaur", + url: "http://localhost:4001/emoji/dino walking.gif", + account_ids: [other_user.id, user.id] + }, + %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]} ] end @@ -66,11 +83,10 @@ test "works correctly with badly formatted emojis" do |> Object.update_data(%{"reactions" => %{"☕" => [user.ap_id], "x" => 1}}) activity = Activity.get_by_id(activity.id) - status = StatusView.render("show.json", activity: activity, for: user) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 1, me: true} + %{name: "☕", count: 1, me: true, url: nil, account_ids: [user.id]} ] end @@ -90,7 +106,7 @@ test "doesn't show reactions from muted and blocked users" do status = StatusView.render("show.json", activity: activity) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 1, me: false} + %{name: "☕", count: 1, me: false, url: nil, account_ids: [other_user.id]} ] status = StatusView.render("show.json", activity: activity, for: user) @@ -102,19 +118,25 @@ test "doesn't show reactions from muted and blocked users" do status = StatusView.render("show.json", activity: activity) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 2, me: false} + %{ + name: "☕", + count: 2, + me: false, + url: nil, + account_ids: [third_user.id, other_user.id] + } ] status = StatusView.render("show.json", activity: activity, for: user) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 1, me: false} + %{name: "☕", count: 1, me: false, url: nil, account_ids: [third_user.id]} ] status = StatusView.render("show.json", activity: activity, for: other_user) assert status[:pleroma][:emoji_reactions] == [ - %{name: "☕", count: 1, me: true} + %{name: "☕", count: 1, me: true, url: nil, account_ids: [other_user.id]} ] end