Remove _misskey_reaction matching (#500)
[akkoma] / lib / pleroma / emoji.ex
index de7fcc1ce0c4f4412038291f740b729327ee794d..933f4275af1d6cec7625b52ad00c9491b620798e 100644 (file)
@@ -1,31 +1,45 @@
 # Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Emoji do
   @moduledoc """
-  The emojis are loaded from:
-
-    * emoji packs in INSTANCE-DIR/emoji
-    * the files: `config/emoji.txt` and `config/custom_emoji.txt`
-    * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder
-
-  This GenServer stores in an ETS table the list of the loaded emojis, and also allows to reload the list at runtime.
+  This GenServer stores in an ETS table the list of the loaded emojis,
+  and also allows to reload the list at runtime.
   """
   use GenServer
 
-  require Logger
+  alias Pleroma.Emoji.Combinations
+  alias Pleroma.Emoji.Loader
 
-  @type pattern :: Regex.t() | module() | String.t()
-  @type patterns :: pattern() | [pattern()]
-  @type group_patterns :: keyword(patterns())
+  require Logger
 
   @ets __MODULE__.Ets
-  @ets_options [:ordered_set, :protected, :named_table, {:read_concurrency, true}]
-  @groups Pleroma.Config.get([:emoji, :groups])
+  @ets_options [
+    :ordered_set,
+    :protected,
+    :named_table,
+    {:read_concurrency, true}
+  ]
+  @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/
+
+  defstruct [:code, :file, :tags, :safe_code, :safe_file]
+
+  @doc "Build emoji struct"
+  def build({code, file, tags}) do
+    %__MODULE__{
+      code: code,
+      file: file,
+      tags: tags,
+      safe_code: Pleroma.HTML.strip_tags(code),
+      safe_file: Pleroma.HTML.strip_tags(file)
+    }
+  end
+
+  def build({code, file}), do: build({code, file, []})
 
   @doc false
-  def start_link do
+  def start_link(_) do
     GenServer.start_link(__MODULE__, [], name: __MODULE__)
   end
 
@@ -38,18 +52,33 @@ defmodule Pleroma.Emoji 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
     end
   end
 
+  @spec exist?(String.t()) :: boolean()
+  def exist?(name), do: not is_nil(get(name))
+
   @doc "Returns all the emojos!!"
-  @spec get_all() :: [{String.t(), String.t()}, ...]
+  @spec get_all() :: list({String.t(), String.t(), String.t()})
   def get_all do
     :ets.tab2list(@ets)
   end
 
+  @doc "Clear out old emojis"
+  def clear_all, do: :ets.delete_all_objects(@ets)
+
   @doc false
   def init(_) do
     @ets = :ets.new(@ets, @ets_options)
@@ -59,13 +88,13 @@ defmodule Pleroma.Emoji do
 
   @doc false
   def handle_cast(:reload, state) do
-    load()
+    update_emojis(Loader.load())
     {:noreply, state}
   end
 
   @doc false
   def handle_call(:reload, _from, state) do
-    load()
+    update_emojis(Loader.load())
     {:reply, :ok, state}
   end
 
@@ -76,183 +105,108 @@ defmodule Pleroma.Emoji do
 
   @doc false
   def code_change(_old_vsn, state, _extra) do
-    load()
+    update_emojis(Loader.load())
     {:ok, state}
   end
 
-  defp load do
-    emoji_dir_path =
-      Path.join(
-        Pleroma.Config.get!([:instance, :static_dir]),
-        "emoji"
-      )
-
-    case File.ls(emoji_dir_path) do
-      {:error, :enoent} ->
-        # The custom emoji directory doesn't exist,
-        # don't do anything
-        nil
-
-      {:error, e} ->
-        # There was some other error
-        Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}")
-
-      {:ok, results} ->
-        grouped = Enum.group_by(results, &File.dir?/1)
-        packs = grouped[true] || []
-        files = grouped[false] || []
-
-        # Print the packs we've found
-        Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}")
-
-        if not Enum.empty?(files) do
-          Logger.warn(
-            "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{
-              Enum.join(files, ", ")
-            }"
-          )
-        end
-
-        emojis =
-          Enum.flat_map(
-            packs,
-            fn pack -> load_pack(Path.join(emoji_dir_path, pack)) end
-          )
-
-        true = :ets.insert(@ets, emojis)
-    end
+  defp update_emojis(emojis) do
+    :ets.insert(@ets, emojis)
+  end
 
-    # Compat thing for old custom emoji handling & default emoji,
-    # it should run even if there are no emoji packs
-    shortcode_globs = Pleroma.Config.get([:emoji, :shortcode_globs], [])
+  @external_resource "lib/pleroma/emoji-test.txt"
 
-    emojis =
-      (load_from_file("config/emoji.txt") ++
-         load_from_file("config/custom_emoji.txt") ++
-         load_from_globs(shortcode_globs))
-      |> Enum.reject(fn value -> value == nil end)
+  regional_indicators =
+    Enum.map(127_462..127_487, fn codepoint ->
+      <<codepoint::utf8>>
+    end)
 
-    true = :ets.insert(@ets, emojis)
+  emojis =
+    @external_resource
+    |> File.read!()
+    |> String.split("\n")
+    |> Enum.filter(fn line ->
+      line != "" and not String.starts_with?(line, "#") and
+        String.contains?(line, "fully-qualified")
+    end)
+    |> Enum.map(fn line ->
+      line
+      |> String.split(";", parts: 2)
+      |> hd()
+      |> String.trim()
+      |> String.split()
+      |> Enum.map(fn codepoint ->
+        <<String.to_integer(codepoint, 16)::utf8>>
+      end)
+      |> Enum.join()
+    end)
+    |> Enum.uniq()
 
-    :ok
-  end
+  emojis = emojis ++ regional_indicators
 
-  defp load_pack(pack_dir) do
-    pack_name = Path.basename(pack_dir)
+  for emoji <- emojis do
+    def is_unicode_emoji?(unquote(emoji)), do: true
+  end
 
-    emoji_txt = Path.join(pack_dir, "emoji.txt")
+  def is_unicode_emoji?(_), do: false
 
-    if File.exists?(emoji_txt) do
-      load_from_file(emoji_txt)
-    else
-      Logger.info(
-        "No emoji.txt found for pack \"#{pack_name}\", assuming all .png files are emoji"
-      )
+  def stripped_name(name) when is_binary(name) do
+    name
+    |> String.replace_leading(":", "")
+    |> String.replace_trailing(":", "")
+  end
 
-      make_shortcode_to_file_map(pack_dir, [".png"])
-      |> Enum.map(fn {shortcode, rel_file} ->
-        filename = Path.join("/emoji/#{pack_name}", rel_file)
+  def stripped_name(name), do: name
 
-        {shortcode, filename, [to_string(match_extra(@groups, filename))]}
-      end)
+  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 make_shortcode_to_file_map(pack_dir, exts) do
-    find_all_emoji(pack_dir, exts)
-    |> Enum.map(&Path.relative_to(&1, pack_dir))
-    |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end)
-    |> Enum.into(%{})
-  end
+  def maybe_quote(name), do: name
 
-  def find_all_emoji(dir, exts) do
-    Enum.reduce(
-      File.ls!(dir),
-      [],
-      fn f, acc ->
-        filepath = Path.join(dir, f)
-
-        if File.dir?(filepath) do
-          acc ++ find_all_emoji(filepath, exts)
-        else
-          acc ++ [filepath]
-        end
-      end
-    )
-    |> Enum.filter(fn f -> Path.extname(f) in exts end)
-  end
+  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)
 
-  defp load_from_file(file) do
-    if File.exists?(file) do
-      load_from_file_stream(File.stream!(file))
+    if is_nil(tag) do
+      nil
     else
-      []
+      tag
+      |> Map.get("icon")
+      |> Map.get("url")
     end
   end
 
-  defp load_from_file_stream(stream) do
-    stream
-    |> Stream.map(&String.trim/1)
-    |> Stream.map(fn line ->
-      case String.split(line, ~r/,\s*/) do
-        [name, file] ->
-          {name, file, [to_string(match_extra(@groups, file))]}
-
-        [name, file | tags] ->
-          {name, file, tags}
+  def emoji_url(_), do: nil
 
-        _ ->
-          nil
-      end
-    end)
-    |> Enum.to_list()
+  def emoji_name_with_instance(name, url) do
+    url = url |> URI.parse() |> Map.get(:host)
+    "#{name}@#{url}"
   end
 
-  defp load_from_globs(globs) do
-    static_path = Path.join(:code.priv_dir(:pleroma), "static")
+  emoji_qualification_map =
+    emojis
+    |> Enum.filter(&String.contains?(&1, "\uFE0F"))
+    |> Combinations.variate_emoji_qualification()
 
-    paths =
-      Enum.map(globs, fn glob ->
-        Path.join(static_path, glob)
-        |> Path.wildcard()
-      end)
-      |> Enum.concat()
-
-    Enum.map(paths, fn path ->
-      tag = match_extra(@groups, Path.join("/", Path.relative_to(path, static_path)))
-      shortcode = Path.basename(path, Path.extname(path))
-      external_path = Path.join("/", Path.relative_to(path, static_path))
-      {shortcode, external_path, [to_string(tag)]}
-    end)
+  for {qualified, unqualified_list} <- emoji_qualification_map do
+    for unqualified <- unqualified_list do
+      def fully_qualify_emoji(unquote(unqualified)), do: unquote(qualified)
+    end
   end
 
-  @doc """
-  Finds a matching group for the given emoji filename
-  """
-  @spec match_extra(group_patterns(), String.t()) :: atom() | nil
-  def match_extra(group_patterns, filename) do
-    match_group_patterns(group_patterns, fn pattern ->
-      case pattern do
-        %Regex{} = regex -> Regex.match?(regex, filename)
-        string when is_binary(string) -> filename == string
-      end
-    end)
-  end
+  def fully_qualify_emoji(emoji), do: emoji
 
-  defp match_group_patterns(group_patterns, matcher) do
-    Enum.find_value(group_patterns, fn {group, patterns} ->
-      patterns =
-        patterns
-        |> List.wrap()
-        |> Enum.map(fn pattern ->
-          if String.contains?(pattern, "*") do
-            ~r(#{String.replace(pattern, "*", ".*")})
-          else
-            pattern
-          end
-        end)
-
-      Enum.any?(patterns, matcher) && group
-    end)
-  end
+  def matches_shortcode?(nil), do: false
+  def matches_shortcode?(s), do: Regex.match?(@emoji_regex, s)
 end