Of course the aprent post might still be deleted after the reply was
already created, but in this case the reply will still show up as a
reply and be federated as a reply with a reference to the parent post.
If the parent was already deleted before the reply gets created however
it used to be indistinguishable from a root post both in Masto API and
ActivityPub.
From a UX perspective, users likely will like to know if the post
they’re replying to no longer exists by the time they finished writing.
The natural language error will show up in akkoma-fe without clearing
the post form, meaning users can decide to discard the reply or copy it
to post as a new root post. It seems sensibly to for other clients to
behave like this too, but so far no more clients were actually tested.
Furthermore, this used to allow replying to all sorts of activities not
just posts which was rather non-sensical (and after all processsing
steps turned into a reply to the object referenced by the activity).
In particular this allowed replying to an user object by specifying the
db ID of a follow request activity (if the latter was somehow obtained).
Note: empty-string in_reply_to parameters are explicitly ignored since
45ebc8dd9a to workaround one buggy client;
see: https://git.pleroma.social/pleroma/pleroma/-/issues/355.
It’s not clear if this workaround is still necessary,
but it is preserved by this commit.
Resolves: https://akkoma.dev/AkkomaGang/akkoma/issues/522
314 lines
9.9 KiB
Elixir
314 lines
9.9 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.Web.CommonAPI.ActivityDraft do
|
|
alias Pleroma.Activity
|
|
alias Pleroma.Conversation.Participation
|
|
alias Pleroma.Object
|
|
alias Pleroma.Web.ActivityPub.Builder
|
|
alias Pleroma.Web.CommonAPI
|
|
alias Pleroma.Web.CommonAPI.Utils
|
|
|
|
import Pleroma.Web.Gettext
|
|
|
|
defstruct valid?: true,
|
|
errors: [],
|
|
user: nil,
|
|
params: %{},
|
|
status: nil,
|
|
summary: nil,
|
|
full_payload: nil,
|
|
attachments: [],
|
|
in_reply_to: nil,
|
|
in_reply_to_conversation: nil,
|
|
language: nil,
|
|
content_map: %{},
|
|
quote_id: nil,
|
|
quote: nil,
|
|
visibility: nil,
|
|
expires_at: nil,
|
|
extra: nil,
|
|
emoji: %{},
|
|
content_html: nil,
|
|
mentions: [],
|
|
tags: [],
|
|
to: [],
|
|
cc: [],
|
|
context: nil,
|
|
sensitive: false,
|
|
object: nil,
|
|
preview?: false,
|
|
changes: %{}
|
|
|
|
defp new(user, params) do
|
|
%__MODULE__{user: user}
|
|
|> put_params(params)
|
|
end
|
|
|
|
def create(user, params) do
|
|
new(user, params)
|
|
|> status()
|
|
|> summary()
|
|
|> with_valid(&attachments/1)
|
|
|> full_payload()
|
|
|> expires_at()
|
|
|> poll()
|
|
|> with_valid(&in_reply_to/1)
|
|
|> with_valid(&in_reply_to_conversation/1)
|
|
|> with_valid(&visibility/1)
|
|
|> with_valid("e_id/1)
|
|
|> content()
|
|
|> with_valid(&language/1)
|
|
|> with_valid(&to_and_cc/1)
|
|
|> with_valid(&context/1)
|
|
|> sensitive()
|
|
|> with_valid(&object/1)
|
|
|> preview?()
|
|
|> with_valid(&changes/1)
|
|
|> validate()
|
|
end
|
|
|
|
defp put_params(draft, params) do
|
|
params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id])
|
|
%__MODULE__{draft | params: params}
|
|
end
|
|
|
|
defp status(%{params: %{status: status}} = draft) do
|
|
%__MODULE__{draft | status: String.trim(status)}
|
|
end
|
|
|
|
defp summary(%{params: params} = draft) do
|
|
%__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")}
|
|
end
|
|
|
|
defp full_payload(%{status: status, summary: summary} = draft) do
|
|
full_payload = String.trim(status <> summary)
|
|
|
|
case Utils.validate_character_limit(full_payload, draft.attachments) do
|
|
:ok -> %__MODULE__{draft | full_payload: full_payload}
|
|
{:error, message} -> add_error(draft, message)
|
|
end
|
|
end
|
|
|
|
defp attachments(%{params: params, user: user} = draft) do
|
|
case Utils.attachments_from_ids(user, params) do
|
|
attachments when is_list(attachments) ->
|
|
%__MODULE__{draft | attachments: attachments}
|
|
|
|
{:error, reason} ->
|
|
add_error(draft, reason)
|
|
end
|
|
end
|
|
|
|
defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft
|
|
|
|
defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do
|
|
# If a post was deleted all its activities (except the newly added Delete) are purged too,
|
|
# thus lookup by Create db ID will yield nil just as if it never existed in the first place.
|
|
# We allow replying to Announce here, due to an akkomafe quirk where if presented with a Announce id
|
|
# it will render it as if it was just the normal referenced post, but use the announce ID for all interaction.
|
|
# (XXX: fix this in akkoma-fe, then drop such workarounds here and in all other affected places)
|
|
with %Activity{} = activity <- Activity.get_by_id(id),
|
|
{_, type} when type in ["Create", "Announce"] <- {:type, activity.data["type"]} do
|
|
%__MODULE__{draft | in_reply_to: activity}
|
|
else
|
|
nil ->
|
|
add_error(draft, dgettext("errors", "Parent post does not exist or was deleted"))
|
|
|
|
{:type, type} ->
|
|
add_error(
|
|
draft,
|
|
dgettext("errors", "Can only reply to posts, not %{type} activities",
|
|
type: inspect(type)
|
|
)
|
|
)
|
|
end
|
|
end
|
|
|
|
defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do
|
|
%__MODULE__{draft | in_reply_to: in_reply_to}
|
|
end
|
|
|
|
defp in_reply_to(draft), do: draft
|
|
|
|
defp in_reply_to_conversation(draft) do
|
|
in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id])
|
|
%__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation}
|
|
end
|
|
|
|
defp quote_id(%{params: %{quote_id: ""}} = draft), do: draft
|
|
|
|
defp quote_id(%{params: %{quote_id: id}} = draft) when is_binary(id) do
|
|
with {:activity, %Activity{} = quote} <- {:activity, Activity.get_by_id(id)},
|
|
visibility <- CommonAPI.get_quoted_visibility(quote),
|
|
{:visibility, true} <- {:visibility, visibility in ["public", "unlisted"]} do
|
|
%__MODULE__{draft | quote: Activity.get_by_id(id)}
|
|
else
|
|
{:activity, _} ->
|
|
add_error(draft, dgettext("errors", "You can't quote a status that doesn't exist"))
|
|
|
|
{:visibility, false} ->
|
|
add_error(draft, dgettext("errors", "You can only quote public or unlisted statuses"))
|
|
end
|
|
end
|
|
|
|
defp quote_id(%{params: %{quote_id: %Activity{} = quote}} = draft) do
|
|
%__MODULE__{draft | quote: quote}
|
|
end
|
|
|
|
defp quote_id(draft), do: draft
|
|
|
|
defp language(%{params: %{language: language}, content_html: content} = draft)
|
|
when is_binary(language) do
|
|
if Pleroma.ISO639.valid_alpha2?(language) do
|
|
%__MODULE__{draft | content_map: %{language => content}}
|
|
else
|
|
add_error(draft, dgettext("errors", "Invalid language"))
|
|
end
|
|
end
|
|
|
|
defp language(%{content_html: content} = draft) do
|
|
# Use a default language if no language is specified
|
|
%__MODULE__{draft | content_map: %{"en" => content}}
|
|
end
|
|
|
|
defp visibility(%{params: params} = draft) do
|
|
case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do
|
|
{visibility, "direct"} when visibility != "direct" ->
|
|
add_error(draft, dgettext("errors", "The message visibility must be direct"))
|
|
|
|
{visibility, _} ->
|
|
%__MODULE__{draft | visibility: visibility}
|
|
end
|
|
end
|
|
|
|
defp expires_at(draft) do
|
|
case CommonAPI.check_expiry_date(draft.params[:expires_in]) do
|
|
{:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at}
|
|
{:error, message} -> add_error(draft, message)
|
|
end
|
|
end
|
|
|
|
defp poll(draft) do
|
|
case Utils.make_poll_data(draft.params) do
|
|
{:ok, {poll, poll_emoji}} ->
|
|
%__MODULE__{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)}
|
|
|
|
{:error, message} ->
|
|
add_error(draft, message)
|
|
end
|
|
end
|
|
|
|
defp content(draft) do
|
|
{content_html, mentioned_users, tags} = Utils.make_content_html(draft)
|
|
|
|
mentions =
|
|
mentioned_users
|
|
|> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end)
|
|
|> Utils.get_addressed_users(draft.params[:to])
|
|
|
|
%__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags}
|
|
end
|
|
|
|
defp to_and_cc(draft) do
|
|
{to, cc} = Utils.get_to_and_cc(draft)
|
|
%__MODULE__{draft | to: to, cc: cc}
|
|
end
|
|
|
|
defp context(draft) do
|
|
context = Utils.make_context(draft)
|
|
%__MODULE__{draft | context: context}
|
|
end
|
|
|
|
defp sensitive(draft) do
|
|
sensitive = draft.params[:sensitive]
|
|
%__MODULE__{draft | sensitive: sensitive}
|
|
end
|
|
|
|
defp object(draft) do
|
|
emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji)
|
|
|
|
# Sometimes people create posts with subject containing emoji,
|
|
# since subjects are usually copied this will result in a broken
|
|
# subject when someone replies from an instance that does not have
|
|
# the emoji or has it under different shortcode. This is an attempt
|
|
# to mitigate this by copying emoji from inReplyTo if they are present
|
|
# in the subject.
|
|
summary_emoji =
|
|
with %Activity{} <- draft.in_reply_to,
|
|
%Object{data: %{"tag" => [_ | _] = tag}} <- Object.normalize(draft.in_reply_to) do
|
|
Enum.reduce(tag, %{}, fn
|
|
%{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}}, acc ->
|
|
if String.contains?(draft.summary, name) do
|
|
Map.put(acc, name, url)
|
|
else
|
|
acc
|
|
end
|
|
|
|
_, acc ->
|
|
acc
|
|
end)
|
|
else
|
|
_ -> %{}
|
|
end
|
|
|
|
emoji = Map.merge(emoji, summary_emoji)
|
|
media_type = Utils.get_content_type(draft.params[:content_type])
|
|
{:ok, note_data, _meta} = Builder.note(draft)
|
|
|
|
object =
|
|
note_data
|
|
|> Map.put("emoji", emoji)
|
|
|> Map.put("source", %{
|
|
"content" => draft.status,
|
|
"mediaType" => media_type
|
|
})
|
|
|> maybe_put("htmlMfm", true, media_type == "text/x.misskeymarkdown")
|
|
|> Map.put("generator", draft.params[:generator])
|
|
|> Map.put("contentMap", draft.content_map)
|
|
|
|
%__MODULE__{draft | object: object}
|
|
end
|
|
|
|
defp maybe_put(map, key, value, true), do: map |> Map.put(key, value)
|
|
defp maybe_put(map, _, _, _), do: map
|
|
|
|
defp preview?(draft) do
|
|
preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview])
|
|
%__MODULE__{draft | preview?: preview?}
|
|
end
|
|
|
|
defp changes(draft) do
|
|
direct? = draft.visibility == "direct"
|
|
additional = %{"cc" => draft.cc, "directMessage" => direct?}
|
|
|
|
additional =
|
|
case draft.expires_at do
|
|
%DateTime{} = expires_at -> Map.put(additional, "expires_at", expires_at)
|
|
_ -> additional
|
|
end
|
|
|
|
changes =
|
|
%{
|
|
to: draft.to,
|
|
actor: draft.user,
|
|
context: draft.context,
|
|
object: draft.object,
|
|
additional: additional
|
|
}
|
|
|> Utils.maybe_add_list_data(draft.user, draft.visibility)
|
|
|
|
%__MODULE__{draft | changes: changes}
|
|
end
|
|
|
|
defp with_valid(%{valid?: true} = draft, func), do: func.(draft)
|
|
defp with_valid(draft, _func), do: draft
|
|
|
|
defp add_error(draft, message) do
|
|
%__MODULE__{draft | valid?: false, errors: [message | draft.errors]}
|
|
end
|
|
|
|
defp validate(%{valid?: true} = draft), do: {:ok, draft}
|
|
defp validate(%{errors: [message | _]}), do: {:error, message}
|
|
end
|