diff --git a/lib/clacks/activitypub.ex b/lib/clacks/activitypub.ex index 9e7cd56..5c991d1 100644 --- a/lib/clacks/activitypub.ex +++ b/lib/clacks/activitypub.ex @@ -33,6 +33,7 @@ defmodule Clacks.ActivityPub do html :: String.t(), context :: String.t() | nil, in_reply_to :: String.t() | nil, + mentions :: [{String.t(), Clacks.Actor.t()}], id :: String.t() | nil, published :: DateTime.t(), to :: [String.t()], @@ -43,6 +44,7 @@ defmodule Clacks.ActivityPub do html, context \\ nil, in_reply_to \\ nil, + mentions \\ [], id \\ nil, published \\ DateTime.utc_now(), to \\ [@public], @@ -64,7 +66,15 @@ defmodule Clacks.ActivityPub do "conversation" => context, "context" => context, "inReplyTo" => in_reply_to, - "published" => published |> DateTime.to_iso8601() + "published" => published |> DateTime.to_iso8601(), + "tag" => + Enum.map(mentions, fn {name, actor} -> + %{ + "href" => actor.ap_id, + "name" => name, + "type" => "Mention" + } + end) } end diff --git a/lib/clacks/user_actions_helper.ex b/lib/clacks/user_actions_helper.ex index 5223824..3290307 100644 --- a/lib/clacks/user_actions_helper.ex +++ b/lib/clacks/user_actions_helper.ex @@ -1,26 +1,35 @@ defmodule Clacks.UserActionsHelper do - alias Clacks.{User, Repo, Activity, Object, ActivityPub} + alias Clacks.{User, Repo, Activity, Object, ActivityPub, Actor} @public "https://www.w3.org/ns/activitystreams#Public" @spec post_status( author :: User.t(), content :: String.t(), + content_type :: String.t(), in_reply_to :: String.t() | Activity.t() | nil ) :: {:ok, Activity.t()} | {:error, any()} - def post_status(author, content, in_reply_to_ap_id) when is_binary(in_reply_to_ap_id) do + def post_status(_, _, content_type, _) + when not (content_type in ["text/plain", "text/markdown", "text/html"]) do + {:error, "invalid content type, must be text/plain, text/markdown, or text/html"} + end + + def post_status(author, content, content_type, in_reply_to_ap_id) + when is_binary(in_reply_to_ap_id) do case Activity.get_by_ap_id(in_reply_to_ap_id) do nil -> {:error, "Could find post to reply to with AP ID '#{in_reply_to_ap_id}'"} in_reply_to -> - post_status(author, content, in_reply_to) + post_status(author, content, content_type, in_reply_to) end end - def post_status(author, content, in_reply_to) do - note = note_for_posting(author, content, in_reply_to) + def post_status(author, content, content_type, in_reply_to) do + {content, mentions} = convert_to_html(content, content_type) + + note = note_for_posting(author, content, mentions, in_reply_to) note_changeset = Object.changeset_for_creating(note) {:ok, _object} = Repo.insert(note_changeset) @@ -35,18 +44,48 @@ defmodule Clacks.UserActionsHelper do end end - defp note_for_posting(author, content, %Activity{ - data: %{"id" => in_reply_to_ap_id, "context" => context, "actor" => in_reply_to_actor} - }) do - to = [in_reply_to_actor, @public] + @spec get_addressed(String.t(), [{String.t(), Actor.t()}], String.t() | nil) :: + {[String.t()], [String.t()]} + defp get_addressed(_author, mentions, in_reply_to_actor) do + to = [@public | Enum.map(mentions, fn {_, actor} -> actor.ap_id end)] # todo: followers cc = [] + to = + case in_reply_to_actor do + nil -> to + in_reply_to_actor -> [in_reply_to_actor | to] + end + + { + Enum.uniq(to), + Enum.uniq(cc) + } + end + + @spec note_for_posting(User.t(), String.t(), [{String.t(), Actor.t()}], Activity.t() | nil) :: + map() + + defp note_for_posting(author, content, mentions, in_reply_to) do + {context, in_reply_to_ap_id, in_reply_to_actor} = + case in_reply_to do + nil -> + {nil, nil, nil} + + %Activity{ + data: %{"id" => in_reply_to_ap_id, "context" => context, "actor" => in_reply_to_actor} + } -> + {context, in_reply_to_ap_id, in_reply_to_actor} + end + + {to, cc} = get_addressed(author, mentions, in_reply_to_actor) + ActivityPub.note( author.actor.ap_id, content, context, in_reply_to_ap_id, + mentions, nil, DateTime.utc_now(), to, @@ -54,7 +93,76 @@ defmodule Clacks.UserActionsHelper do ) end - defp note_for_posting(author, content, _in_reply_to) do - ActivityPub.note(author.actor.ap_id, content) + @spec convert_to_html(String.t(), String.t()) :: String.t() + + defp convert_to_html(content, "text/html") do + content + |> replace_mentions() + end + + defp convert_to_html(content, "text/markdown") do + content + |> Earmark.as_html!() + |> replace_mentions() + end + + defp convert_to_html(content, "text/plain") do + {content, mentions} = + content + |> HtmlEntities.encode() + |> replace_links() + |> replace_mentions() + + {String.replace(content, ["\r\n", "\n"], "
"), mentions} + end + + @link_regex ~r/\bhttps?\S+\b/i + + defp replace_links(content) do + Regex.replace(@link_regex, content, "\\1") + end + + @mention_regex ~r/@(([a-z0-9_]+)(?:@[a-z0-9_\-.]+)?)/i + + @spec replace_mentions(String.t()) :: {String.t(), [{String.t(), Actor.t()}]} + + defp replace_mentions(content) do + Regex.scan(@mention_regex, content, return: :index) + |> Enum.reverse() + |> Enum.reduce({content, []}, fn [ + {match_start, match_length}, + {nickname_start, nickname_length}, + {username_start, username_length} + ], + {content, mentions} -> + nickname = String.slice(content, nickname_start, nickname_length) + + Actor.get_by_nickname(nickname) + |> case do + %Actor{} = actor -> + actor + + nil -> + # todo: fetch by webfinger + raise "unimplemented" + end + |> case do + nil -> + {content, mentions} + + actor -> + username = String.slice(content, username_start, username_length) + + html = + "@#{username}" + + new_content = + String.slice(content, 0, match_start) <> + html <> + String.slice(content, (match_start + match_length)..String.length(content)) + + {new_content, [{nickname, actor} | mentions]} + end + end) end end diff --git a/lib/clacks_web/controllers/frontend_controller.ex b/lib/clacks_web/controllers/frontend_controller.ex index 0041a0b..6e2887c 100644 --- a/lib/clacks_web/controllers/frontend_controller.ex +++ b/lib/clacks_web/controllers/frontend_controller.ex @@ -254,7 +254,12 @@ defmodule ClacksWeb.FrontendController do def post_status(conn, %{"content" => content} = params) do current_user = conn.assigns[:user] |> Repo.preload(:actor) - UserActionsHelper.post_status(current_user, content, Map.get(params, "in_reply_to")) + UserActionsHelper.post_status( + current_user, + content, + "text/plain", + Map.get(params, "in_reply_to") + ) |> case do {:ok, activity} -> path = Map.get(params, "continue", Routes.frontend_path(Endpoint, :status, activity.id)) diff --git a/lib/clacks_web/templates/frontend/_status.html.eex b/lib/clacks_web/templates/frontend/_status.html.eex index f4e3cc7..f1284dc 100644 --- a/lib/clacks_web/templates/frontend/_status.html.eex +++ b/lib/clacks_web/templates/frontend/_status.html.eex @@ -16,7 +16,7 @@

- <%= @note["content"] %> + <%= raw(@note["content"]) %>
Reply diff --git a/mix.exs b/mix.exs index 466e1e0..d3cde11 100644 --- a/mix.exs +++ b/mix.exs @@ -54,7 +54,9 @@ defmodule Clacks.MixProject do {:fast_sanitize, "~> 0.1.7"}, {:fast_html, "~> 1.0.3"}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, - {:floki, "~> 0.26.0"} + {:floki, "~> 0.26.0"}, + {:earmark, "~> 1.4.4"}, + {:html_entities, "~> 0.5.1"} ] end diff --git a/mix.lock b/mix.lock index b21e7f6..3e37fc5 100644 --- a/mix.lock +++ b/mix.lock @@ -11,6 +11,7 @@ "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "5a0e8c1c722dbcd31c0cbd1906b1d1074c863d335c295e4b994849b65a1fbe47"}, "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm", "52694ef56e60108e5012f8af9673874c66ed58ac1c4fae9b5b7ded31786663f5"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, + "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, "ecto": {:hex, :ecto, "3.2.1", "a0f9af0fb50b19d3bb6237e512ac0ba56ea222c2bbea92e7c6c94897932c76ba", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "e1a1a1d72514b880d6bdd9fe9423d13a800ec1fb041c7239d885e5407b1fabce"}, "ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a2e23cf761668126252418cae07eff7967ad0152fbc5e2d0dc3de487a5ec774c"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},