1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.Web.MastodonAPI.StatusView do
8 require Pleroma.Constants
10 alias Pleroma.Activity
16 alias Pleroma.UserRelationship
17 alias Pleroma.Web.CommonAPI
18 alias Pleroma.Web.CommonAPI.Utils
19 alias Pleroma.Web.MastodonAPI.AccountView
20 alias Pleroma.Web.MastodonAPI.PollView
21 alias Pleroma.Web.MastodonAPI.StatusView
22 alias Pleroma.Web.MediaProxy
23 alias Pleroma.Web.PleromaAPI.EmojiReactionController
25 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
27 # This is a naive way to do this, just spawning a process per activity
28 # to fetch the preview. However it should be fine considering
29 # pagination is restricted to 40 activities at a time
30 defp fetch_rich_media_for_activities(activities) do
31 Enum.each(activities, fn activity ->
33 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
38 # TODO: Add cached version.
39 defp get_replied_to_activities([]), do: %{}
41 defp get_replied_to_activities(activities) do
44 %{data: %{"type" => "Create"}} = activity ->
45 object = Object.normalize(activity, fetch: false)
46 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
52 |> Activity.create_by_object_ap_id_with_object()
54 |> Enum.reduce(%{}, fn activity, acc ->
55 object = Object.normalize(activity, fetch: false)
56 if object, do: Map.put(acc, object.data["id"], activity), else: acc
60 # DEPRECATED This field seems to be a left-over from the StatusNet era.
61 # If your application uses `pleroma.conversation_id`: this field is deprecated.
62 # It is currently stubbed instead by doing a CRC32 of the context, and
63 # clearing the MSB to avoid overflow exceptions with signed integers on the
64 # different clients using this field (Java/Kotlin code, mostly; see Husky.)
65 # This should be removed in a future version of Pleroma. Pleroma-FE currently
66 # depends on this field, as well.
67 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do
70 :erlang.crc32(context)
71 |> band(bnot(0x8000_0000))
74 defp get_context_id(_), do: nil
76 # Check if the user reblogged this status
77 defp reblogged?(activity, %User{ap_id: ap_id}) do
78 with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
79 Object.normalize(activity, fetch: false) do
80 ap_id in announcements
86 # False if the user is logged out
87 defp reblogged?(_activity, _user), do: false
89 def render("index.json", opts) do
90 reading_user = opts[:for]
91 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
92 activities = Enum.filter(opts.activities, & &1)
94 # Start fetching rich media before doing anything else, so that later calls to get the cards
95 # only block for timeout in the worst case, as opposed to
96 # length(activities_with_links) * timeout
97 fetch_rich_media_for_activities(activities)
98 replied_to_activities = get_replied_to_activities(activities)
102 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
103 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
104 |> Activity.create_by_object_ap_id()
105 |> Activity.with_preloaded_object(:left)
106 |> Activity.with_preloaded_bookmark(reading_user)
107 |> Activity.with_set_thread_muted_field(reading_user)
112 Map.has_key?(opts, :relationships) ->
115 is_nil(reading_user) ->
116 UserRelationship.view_relationships_option(nil, [])
119 # Note: unresolved users are filtered out
121 (activities ++ parent_activities)
122 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
125 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
130 |> Map.put(:replied_to_activities, replied_to_activities)
131 |> Map.put(:parent_activities, parent_activities)
132 |> Map.put(:relationships, relationships_opt)
134 render_many(activities, StatusView, "show.json", opts)
139 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
141 user = CommonAPI.get_user(activity.data["actor"])
142 created_at = Utils.to_masto_date(activity.data["published"])
143 object = Object.normalize(activity, fetch: false)
145 reblogged_parent_activity =
146 if opts[:parent_activities] do
147 Activity.Queries.find_by_object_ap_id(
148 opts[:parent_activities],
152 Activity.create_by_object_ap_id(object.data["id"])
153 |> Activity.with_preloaded_bookmark(opts[:for])
154 |> Activity.with_set_thread_muted_field(opts[:for])
158 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
159 reblogged = render("show.json", reblog_rendering_opts)
161 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
163 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
167 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
169 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
171 {pinned?, pinned_at} = pin_data(object, user)
172 lang = language(object)
175 id: to_string(activity.id),
176 uri: object.data["id"],
177 url: object.data["id"],
179 AccountView.render("show.json", %{
184 in_reply_to_account_id: nil,
187 created_at: created_at,
191 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
192 favourited: present?(favorited),
193 bookmarked: present?(bookmarked),
198 visibility: get_visibility(activity),
199 media_attachments: reblogged[:media_attachments] || [],
201 tags: reblogged[:tags] || [],
202 application: build_application(object.data["generator"]),
206 local: activity.local,
212 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
213 with %Object{} = object <- Object.normalize(activity, fetch: false) do
214 user = CommonAPI.get_user(activity.data["actor"])
215 user_follower_address = user.follower_address
217 like_count = object.data["like_count"] || 0
218 announcement_count = object.data["announcement_count"] || 0
220 hashtags = Object.hashtags(object)
221 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
223 tags = Object.tags(object)
227 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
228 |> Enum.map(fn tag -> tag["href"] end)
231 (object.data["to"] ++ tag_mentions)
234 Pleroma.Constants.as_public() -> nil
235 ^user_follower_address -> nil
236 ap_id -> User.get_cached_by_ap_id(ap_id)
239 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
241 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
243 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
245 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
248 with true <- client_posted_this_activity,
249 %Oban.Job{scheduled_at: scheduled_at} <-
250 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
258 is_nil(opts[:for]) -> false
259 is_boolean(activity.thread_muted?) -> activity.thread_muted?
260 true -> CommonAPI.thread_muted?(opts[:for], activity)
263 attachment_data = object.data["attachment"] || []
264 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
266 created_at = Utils.to_masto_date(object.data["published"])
269 with %{"updated" => updated} <- object.data,
270 date <- Utils.to_masto_date(updated),
271 true <- date != "" do
278 reply_to = get_reply_to(activity, opts)
280 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
284 (Object.Updater.history_for(object.data)
285 |> Map.get("orderedItems")
288 # See render("history.json", ...) for more details
289 # Here the implicit index of the current content is 0
290 chrono_order = history_len - 1
298 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
299 User.html_filter_policy(opts[:for]),
301 "mastoapi:content:#{chrono_order}"
306 |> Activity.HTML.get_cached_stripped_html_for_activity(
308 "mastoapi:content:#{chrono_order}"
311 summary = object.data["summary"] || ""
313 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
317 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
319 object.data["url"] || object.data["external_url"] || object.data["id"]
322 direct_conversation_id =
323 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
324 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
325 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
326 Activity.direct_conversation_id(activity, for_user)
328 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
337 |> Map.get("reactions", [])
338 |> EmojiReactionController.filter_allowed_users(
340 Map.get(opts, :with_muted, false)
342 |> Stream.map(fn {emoji, users, url} ->
343 build_emoji_map(emoji, users, url, opts[:for])
347 # Status muted state (would do 1 request per status unless user mutes are preloaded)
350 UserRelationship.exists?(
351 get_in(opts, [:relationships, :user_relationships]),
355 fn for_user, user -> User.mutes?(for_user, user) end
358 {pinned?, pinned_at} = pin_data(object, user)
360 quote = Activity.get_quoted_activity_from_object(object)
361 lang = language(object)
364 id: to_string(activity.id),
365 uri: object.data["id"],
368 AccountView.render("show.json", %{
372 in_reply_to_id: reply_to && to_string(reply_to.id),
373 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
376 content: content_html,
377 text: opts[:with_source] && get_source_text(object.data["source"]),
378 created_at: created_at,
379 edited_at: edited_at,
380 reblogs_count: announcement_count,
381 replies_count: object.data["repliesCount"] || 0,
382 favourites_count: like_count,
383 reblogged: reblogged?(activity, opts[:for]),
384 favourited: present?(favorited),
385 bookmarked: present?(bookmarked),
388 sensitive: sensitive,
389 spoiler_text: summary,
390 visibility: get_visibility(object),
391 media_attachments: attachments,
392 poll: render(PollView, "show.json", object: object, for: opts[:for]),
394 tags: build_tags(tags),
395 application: build_application(object.data["generator"]),
397 emojis: build_emojis(object.data["emoji"]),
398 quote_id: if(quote, do: quote.id, else: nil),
399 quote: maybe_render_quote(quote, opts),
400 emoji_reactions: emoji_reactions,
402 local: activity.local,
403 conversation_id: get_context_id(activity),
404 context: object.data["context"],
405 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
406 content: %{"text/plain" => content_plaintext},
407 spoiler_text: %{"text/plain" => summary},
408 expires_at: expires_at,
409 direct_conversation_id: direct_conversation_id,
410 thread_muted: thread_muted?,
411 emoji_reactions: emoji_reactions,
412 parent_visible: visible_for_user?(reply_to, opts[:for]),
416 source: object.data["source"]
424 def render("show.json", _) do
428 def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
429 object = Object.normalize(activity, fetch: false)
431 hashtags = Object.hashtags(object)
433 user = CommonAPI.get_user(activity.data["actor"])
436 Object.Updater.history_for(object.data)
437 |> Map.get("orderedItems")
438 |> Enum.map(&Map.put(&1, "id", object.data["id"]))
439 |> Enum.map(&%Object{data: &1, id: object.id})
442 [object | past_history]
443 # Mastodon expects the original to be at the first
446 |> Enum.map(fn {object, chrono_order} ->
448 # The history is prepended every time there is a new edit.
449 # In chrono_order, the oldest item is always at 0, and so on.
450 # The chrono_order is an invariant kept between edits.
451 chrono_order: chrono_order,
458 |> Map.put(:as, :item)
459 |> Map.put(:user, user)
460 |> Map.put(:hashtags, hashtags)
462 render_many(history, StatusView, "history_item.json", individual_opts)
470 item: %{object: object, chrono_order: chrono_order},
474 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
476 attachment_data = object.data["attachment"] || []
477 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
479 created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
487 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
488 User.html_filter_policy(opts[:for]),
490 "mastoapi:content:#{chrono_order}"
493 summary = object.data["summary"] || ""
497 AccountView.render("show.json", %{
501 content: content_html,
502 sensitive: sensitive,
503 spoiler_text: summary,
504 created_at: created_at,
505 media_attachments: attachments,
506 emojis: build_emojis(object.data["emoji"]),
507 poll: render(PollView, "show.json", object: object, for: opts[:for])
511 def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
512 object = Object.normalize(activity, fetch: false)
516 text: get_source_text(Map.get(object.data, "source", "")),
517 spoiler_text: Map.get(object.data, "summary", ""),
518 content_type: get_source_content_type(object.data["source"])
522 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
523 page_url_data = URI.parse(page_url)
526 if is_binary(rich_media["url"]) do
527 URI.merge(page_url_data, URI.parse(rich_media["url"]))
532 page_url = page_url_data |> to_string
535 if is_binary(rich_media["image"]) do
536 URI.parse(rich_media["image"])
541 image_url = build_image_url(image_url_data, page_url_data)
545 provider_name: page_url_data.host,
546 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
548 image: image_url |> MediaProxy.url(),
549 title: rich_media["title"] || "",
550 description: rich_media["description"] || "",
552 opengraph: rich_media
557 def render("card.json", _), do: nil
559 def render("attachment.json", %{attachment: attachment}) do
560 [attachment_url | _] = attachment["url"]
561 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
562 href = attachment_url["href"] |> MediaProxy.url()
563 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
564 meta = render("attachment_meta.json", %{attachment: attachment})
568 String.contains?(media_type, "image") -> "image"
569 String.contains?(media_type, "video") -> "video"
570 String.contains?(media_type, "audio") -> "audio"
575 with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
576 {_, %Object{data: _object_data, id: object_id}} <-
577 {:object, Object.get_by_ap_id(ap_id)} do
581 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
582 to_string(attachment["id"] || hash_id)
589 preview_url: href_preview,
592 description: attachment["name"],
593 pleroma: %{mime_type: media_type},
594 blurhash: attachment["blurhash"]
596 |> Maps.put_if_present(:meta, meta)
599 def render("attachment_meta.json", %{
600 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
602 when is_integer(width) and is_integer(height) do
607 aspect: width / height
612 def render("attachment_meta.json", _), do: nil
614 def render("context.json", %{activity: activity, activities: activities, user: user}) do
615 %{ancestors: ancestors, descendants: descendants} =
618 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
619 |> Map.put_new(:ancestors, [])
620 |> Map.put_new(:descendants, [])
623 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
624 descendants: render("index.json", for: user, activities: descendants, as: :activity)
628 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
629 object = Object.normalize(activity, fetch: false)
631 with nil <- replied_to_activities[object.data["inReplyTo"]] do
632 # If user didn't participate in the thread
633 Activity.get_in_reply_to_activity(activity)
637 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
638 object = Object.normalize(activity, fetch: false)
640 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
641 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
647 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
648 url = object.data["url"] || object.data["id"]
650 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
653 def render_content(object), do: object.data["content"] || ""
656 Builds a dictionary tags.
660 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
661 [{"name": "fediverse", "url": "/tag/fediverse"},
662 {"name": "nextcloud", "url": "/tag/nextcloud"}]
665 @spec build_tags(list(any())) :: list(map())
666 def build_tags(object_tags) when is_list(object_tags) do
668 |> Enum.filter(&is_binary/1)
669 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
672 def build_tags(_), do: []
677 Arguments: `nil` or list tuple of name and url.
683 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
684 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
687 @spec build_emojis(nil | list(tuple())) :: list(map())
688 def build_emojis(nil), do: []
690 def build_emojis(emojis) do
692 |> Enum.map(fn {name, url} ->
693 name = HTML.strip_tags(name)
700 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
704 defp present?(nil), do: false
705 defp present?(false), do: false
706 defp present?(_), do: true
708 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
709 if pinned_at = pinned_objects[object_id] do
710 {true, Utils.to_masto_date(pinned_at)}
716 defp build_emoji_map(emoji, users, url, current_user) do
718 name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
719 count: length(users),
720 url: MediaProxy.url(url),
721 me: !!(current_user && current_user.ap_id in users),
722 account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
726 @spec build_application(map() | nil) :: map() | nil
727 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
728 do: %{name: name, website: url}
730 defp build_application(_), do: nil
732 # Workaround for Elixir issue #10771
733 # Avoid applying URI.merge unless necessary
734 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
735 # when Elixir 1.12 is the minimum supported version
736 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
737 defp build_image_url(
738 %URI{scheme: image_scheme, host: image_host} = image_url_data,
739 %URI{} = _page_url_data
741 when not is_nil(image_scheme) and not is_nil(image_host) do
742 image_url_data |> to_string
745 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
746 URI.merge(page_url_data, image_url_data) |> to_string
749 defp build_image_url(_, _), do: nil
751 defp maybe_render_quote(nil, _), do: nil
753 defp maybe_render_quote(quote, opts) do
754 with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
755 false <- Map.get(opts, :do_not_recurse, false),
756 true <- visible_for_user?(quote, opts[:for]),
757 false <- User.blocks?(opts[:for], quoted_user),
758 false <- User.mutes?(opts[:for], quoted_user) do
761 |> Map.put(:activity, quote)
762 |> Map.put(:do_not_recurse, true)
764 render("show.json", opts)
770 defp get_source_text(%{"content" => content} = _source) do
774 defp get_source_text(source) when is_binary(source) do
778 defp get_source_text(_) do
782 defp get_source_content_type(%{"mediaType" => type} = _source) do
786 defp get_source_content_type(_source) do
787 Utils.get_content_type(nil)
790 defp language(%Object{data: %{"contentMap" => contentMap}}) when is_map(contentMap) do
796 defp language(_), do: nil