defmodule Clacks.UserActionsHelper do alias Clacks.{User, Repo, Activity, Object, ActivityPub, Actor, Notification} @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(_, _, 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, content_type, in_reply_to) end end 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) %{"id" => ap_id} = create = ActivityPub.create(note) case ActivityPub.Helper.save_and_federate(create, author.actor) do {:ok, activity} -> Notification.process_notifications_for_incoming(activity) {:ok, activity} :error -> {:error, "Unable to save and federate activity with ID '#{ap_id}'"} end end @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, cc ) end @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