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 @@