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
26 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
28 # This is a naive way to do this, just spawning a process per activity
29 # to fetch the preview. However it should be fine considering
30 # pagination is restricted to 40 activities at a time
31 defp fetch_rich_media_for_activities(activities) do
32 Enum.each(activities, fn activity ->
34 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
39 # TODO: Add cached version.
40 defp get_replied_to_activities([]), do: %{}
42 defp get_replied_to_activities(activities) do
45 %{data: %{"type" => "Create"}} = activity ->
46 object = Object.normalize(activity, fetch: false)
47 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
53 |> Activity.create_by_object_ap_id_with_object()
55 |> Enum.reduce(%{}, fn activity, acc ->
56 object = Object.normalize(activity, fetch: false)
57 if object, do: Map.put(acc, object.data["id"], activity), else: acc
61 # DEPRECATED This field seems to be a left-over from the StatusNet era.
62 # If your application uses `pleroma.conversation_id`: this field is deprecated.
63 # It is currently stubbed instead by doing a CRC32 of the context, and
64 # clearing the MSB to avoid overflow exceptions with signed integers on the
65 # different clients using this field (Java/Kotlin code, mostly; see Husky.)
66 # This should be removed in a future version of Pleroma. Pleroma-FE currently
67 # depends on this field, as well.
68 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do
71 :erlang.crc32(context)
72 |> band(bnot(0x8000_0000))
75 defp get_context_id(_), do: nil
77 # Check if the user reblogged this status
78 defp reblogged?(activity, %User{ap_id: ap_id}) do
79 with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
80 Object.normalize(activity, fetch: false) do
81 ap_id in announcements
87 # False if the user is logged out
88 defp reblogged?(_activity, _user), do: false
90 def render("index.json", opts) do
91 Logger.debug("Rendering index")
92 reading_user = opts[:for]
93 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
94 activities = Enum.filter(opts.activities, & &1)
96 # Start fetching rich media before doing anything else, so that later calls to get the cards
97 # only block for timeout in the worst case, as opposed to
98 # length(activities_with_links) * timeout
99 fetch_rich_media_for_activities(activities)
100 replied_to_activities = get_replied_to_activities(activities)
104 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
105 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
106 |> Activity.create_by_object_ap_id()
107 |> Activity.with_preloaded_object(:left)
108 |> Activity.with_preloaded_bookmark(reading_user)
109 |> Activity.with_set_thread_muted_field(reading_user)
114 Map.has_key?(opts, :relationships) ->
117 is_nil(reading_user) ->
118 UserRelationship.view_relationships_option(nil, [])
121 # Note: unresolved users are filtered out
123 (activities ++ parent_activities)
124 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
127 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
132 |> Map.put(:replied_to_activities, replied_to_activities)
133 |> Map.put(:parent_activities, parent_activities)
134 |> Map.put(:relationships, relationships_opt)
136 render_many(activities, StatusView, "show.json", opts)
141 %{activity: %{id: id, data: %{"type" => "Announce", "object" => _object}} = activity} =
144 Logger.debug("Rendering reblog #{id}")
145 user = CommonAPI.get_user(activity.data["actor"])
146 created_at = Utils.to_masto_date(activity.data["published"])
147 object = Object.normalize(activity, fetch: false)
149 reblogged_parent_activity =
150 if opts[:parent_activities] do
151 Activity.Queries.find_by_object_ap_id(
152 opts[:parent_activities],
156 Activity.create_by_object_ap_id(object.data["id"])
157 |> Activity.with_preloaded_bookmark(opts[:for])
158 |> Activity.with_set_thread_muted_field(opts[:for])
162 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
163 reblogged = render("show.json", reblog_rendering_opts)
165 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
167 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
171 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
173 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
175 {pinned?, pinned_at} = pin_data(object, user)
176 lang = language(object)
179 id: to_string(activity.id),
180 uri: object.data["id"],
181 url: object.data["id"],
183 AccountView.render("show.json", %{
188 in_reply_to_account_id: nil,
191 created_at: created_at,
195 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
196 favourited: present?(favorited),
197 bookmarked: present?(bookmarked),
202 visibility: get_visibility(activity),
203 media_attachments: reblogged[:media_attachments] || [],
205 tags: reblogged[:tags] || [],
206 application: build_application(object.data["generator"]),
210 local: activity.local,
216 def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = activity} = opts) do
217 Logger.debug("Rendering status #{id}")
219 with %Object{} = object <- Object.normalize(activity, fetch: false) do
220 user = CommonAPI.get_user(activity.data["actor"])
221 user_follower_address = user.follower_address
223 like_count = object.data["like_count"] || 0
224 announcement_count = object.data["announcement_count"] || 0
226 hashtags = Object.hashtags(object)
227 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
229 tags = Object.tags(object)
233 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
234 |> Enum.map(fn tag -> tag["href"] end)
236 to_data = if is_nil(object.data["to"]), do: [], else: object.data["to"]
239 (to_data ++ tag_mentions)
242 Pleroma.Constants.as_public() -> nil
243 ^user_follower_address -> nil
244 ap_id -> User.get_cached_by_ap_id(ap_id)
247 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
249 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
251 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
253 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
256 with true <- client_posted_this_activity,
257 %Oban.Job{scheduled_at: scheduled_at} <-
258 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
266 is_nil(opts[:for]) -> false
267 is_boolean(activity.thread_muted?) -> activity.thread_muted?
268 true -> CommonAPI.thread_muted?(opts[:for], activity)
271 attachment_data = object.data["attachment"] || []
272 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
274 created_at = Utils.to_masto_date(object.data["published"])
277 with %{"updated" => updated} <- object.data,
278 date <- Utils.to_masto_date(updated),
279 true <- date != "" do
286 reply_to = get_reply_to(activity, opts)
288 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
292 (Object.Updater.history_for(object.data)
293 |> Map.get("orderedItems")
296 # See render("history.json", ...) for more details
297 # Here the implicit index of the current content is 0
298 chrono_order = history_len - 1
306 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
307 User.html_filter_policy(opts[:for]),
309 "mastoapi:content:#{chrono_order}"
314 |> Activity.HTML.get_cached_stripped_html_for_activity(
316 "mastoapi:content:#{chrono_order}"
319 summary = object.data["summary"] || ""
321 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
325 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
327 object.data["url"] || object.data["external_url"] || object.data["id"]
330 direct_conversation_id =
331 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
332 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
333 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
334 Activity.direct_conversation_id(activity, for_user)
336 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
345 |> Map.get("reactions", [])
346 |> EmojiReactionController.filter_allowed_users(
348 Map.get(opts, :with_muted, false)
350 |> Stream.map(fn {emoji, users, url} ->
351 build_emoji_map(emoji, users, url, opts[:for])
355 # Status muted state (would do 1 request per status unless user mutes are preloaded)
358 UserRelationship.exists?(
359 get_in(opts, [:relationships, :user_relationships]),
363 fn for_user, user -> User.mutes?(for_user, user) end
366 {pinned?, pinned_at} = pin_data(object, user)
368 quote = Activity.get_quoted_activity_from_object(object)
369 lang = language(object)
372 id: to_string(activity.id),
373 uri: object.data["id"],
376 AccountView.render("show.json", %{
380 in_reply_to_id: reply_to && to_string(reply_to.id),
381 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
384 content: content_html,
385 text: opts[:with_source] && get_source_text(object.data["source"]),
386 created_at: created_at,
387 edited_at: edited_at,
388 reblogs_count: announcement_count,
389 replies_count: object.data["repliesCount"] || 0,
390 favourites_count: like_count,
391 reblogged: reblogged?(activity, opts[:for]),
392 favourited: present?(favorited),
393 bookmarked: present?(bookmarked),
396 sensitive: sensitive,
397 spoiler_text: summary,
398 visibility: get_visibility(object),
399 media_attachments: attachments,
400 poll: render(PollView, "show.json", object: object, for: opts[:for]),
402 tags: build_tags(tags),
403 application: build_application(object.data["generator"]),
405 emojis: build_emojis(object.data["emoji"]),
406 quote_id: if(quote, do: quote.id, else: nil),
407 quote: maybe_render_quote(quote, opts),
408 emoji_reactions: emoji_reactions,
410 local: activity.local,
411 conversation_id: get_context_id(activity),
412 context: object.data["context"],
413 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
414 content: %{"text/plain" => content_plaintext},
415 spoiler_text: %{"text/plain" => summary},
416 expires_at: expires_at,
417 direct_conversation_id: direct_conversation_id,
418 thread_muted: thread_muted?,
419 emoji_reactions: emoji_reactions,
420 parent_visible: visible_for_user?(reply_to, opts[:for]),
424 source: object.data["source"]
432 def render("show.json", _) do
436 def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
437 Logger.debug("Rendering history for #{activity.id}")
438 object = Object.normalize(activity, fetch: false)
440 hashtags = Object.hashtags(object)
442 user = CommonAPI.get_user(activity.data["actor"])
445 Object.Updater.history_for(object.data)
446 |> Map.get("orderedItems")
447 |> Enum.map(&Map.put(&1, "id", object.data["id"]))
448 |> Enum.map(&%Object{data: &1, id: object.id})
451 [object | past_history]
452 # Mastodon expects the original to be at the first
455 |> Enum.map(fn {object, chrono_order} ->
457 # The history is prepended every time there is a new edit.
458 # In chrono_order, the oldest item is always at 0, and so on.
459 # The chrono_order is an invariant kept between edits.
460 chrono_order: chrono_order,
467 |> Map.put(:as, :item)
468 |> Map.put(:user, user)
469 |> Map.put(:hashtags, hashtags)
471 render_many(history, StatusView, "history_item.json", individual_opts)
479 item: %{object: object, chrono_order: chrono_order},
483 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
485 attachment_data = object.data["attachment"] || []
486 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
488 created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
496 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
497 User.html_filter_policy(opts[:for]),
499 "mastoapi:content:#{chrono_order}"
502 summary = object.data["summary"] || ""
506 AccountView.render("show.json", %{
510 content: content_html,
511 sensitive: sensitive,
512 spoiler_text: summary,
513 created_at: created_at,
514 media_attachments: attachments,
515 emojis: build_emojis(object.data["emoji"]),
516 poll: render(PollView, "show.json", object: object, for: opts[:for])
520 def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
521 object = Object.normalize(activity, fetch: false)
525 text: get_source_text(Map.get(object.data, "source", "")),
526 spoiler_text: Map.get(object.data, "summary", ""),
527 content_type: get_source_content_type(object.data["source"])
531 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
532 page_url_data = URI.parse(page_url)
535 if is_binary(rich_media["url"]) do
536 URI.merge(page_url_data, URI.parse(rich_media["url"]))
541 page_url = page_url_data |> to_string
544 if is_binary(rich_media["image"]) do
545 URI.parse(rich_media["image"])
550 image_url = build_image_url(image_url_data, page_url_data)
554 provider_name: page_url_data.host,
555 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
557 image: image_url |> MediaProxy.url(),
558 title: rich_media["title"] || "",
559 description: rich_media["description"] || "",
561 opengraph: rich_media
566 def render("card.json", _), do: nil
568 def render("attachment.json", %{attachment: attachment}) do
569 [attachment_url | _] = attachment["url"]
570 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
571 href = attachment_url["href"] |> MediaProxy.url()
572 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
573 meta = render("attachment_meta.json", %{attachment: attachment})
577 String.contains?(media_type, "image") -> "image"
578 String.contains?(media_type, "video") -> "video"
579 String.contains?(media_type, "audio") -> "audio"
584 with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
585 {_, %Object{data: _object_data, id: object_id}} <-
586 {:object, Object.get_by_ap_id(ap_id)} do
590 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
591 to_string(attachment["id"] || hash_id)
598 preview_url: href_preview,
601 description: attachment["name"],
602 pleroma: %{mime_type: media_type},
603 blurhash: attachment["blurhash"]
605 |> Maps.put_if_present(:meta, meta)
608 def render("attachment_meta.json", %{
609 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
611 when is_integer(width) and is_integer(height) do
616 aspect: width / height
621 def render("attachment_meta.json", _), do: nil
623 def render("context.json", %{activity: activity, activities: activities, user: user}) do
624 Logger.debug("Rendering context for #{activity.id}")
626 %{ancestors: ancestors, descendants: descendants} =
629 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
630 |> Map.put_new(:ancestors, [])
631 |> Map.put_new(:descendants, [])
634 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
635 descendants: render("index.json", for: user, activities: descendants, as: :activity)
639 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
640 object = Object.normalize(activity, fetch: false)
642 with nil <- replied_to_activities[object.data["inReplyTo"]] do
643 # If user didn't participate in the thread
644 Activity.get_in_reply_to_activity(activity)
648 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
649 object = Object.normalize(activity, fetch: false)
651 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
652 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
658 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
659 url = object.data["url"] || object.data["id"]
661 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
664 def render_content(object), do: object.data["content"] || ""
667 Builds a dictionary tags.
671 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
672 [{"name": "fediverse", "url": "/tag/fediverse"},
673 {"name": "nextcloud", "url": "/tag/nextcloud"}]
676 @spec build_tags(list(any())) :: list(map())
677 def build_tags(object_tags) when is_list(object_tags) do
679 |> Enum.filter(&is_binary/1)
680 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
683 def build_tags(_), do: []
688 Arguments: `nil` or list tuple of name and url.
694 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
695 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
698 @spec build_emojis(nil | list(tuple())) :: list(map())
699 def build_emojis(nil), do: []
701 def build_emojis(emojis) do
703 |> Enum.map(fn {name, url} ->
704 name = HTML.strip_tags(name)
711 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
715 defp present?(nil), do: false
716 defp present?(false), do: false
717 defp present?(_), do: true
719 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
720 if pinned_at = pinned_objects[object_id] do
721 {true, Utils.to_masto_date(pinned_at)}
727 defp build_emoji_map(emoji, users, url, current_user) do
729 name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
730 count: length(users),
731 url: MediaProxy.url(url),
732 me: !!(current_user && current_user.ap_id in users),
733 account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
737 @spec build_application(map() | nil) :: map() | nil
738 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
739 do: %{name: name, website: url}
741 defp build_application(_), do: nil
743 # Workaround for Elixir issue #10771
744 # Avoid applying URI.merge unless necessary
745 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
746 # when Elixir 1.12 is the minimum supported version
747 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
748 defp build_image_url(
749 %URI{scheme: image_scheme, host: image_host} = image_url_data,
750 %URI{} = _page_url_data
752 when not is_nil(image_scheme) and not is_nil(image_host) do
753 image_url_data |> to_string
756 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
757 URI.merge(page_url_data, image_url_data) |> to_string
760 defp build_image_url(_, _), do: nil
762 defp maybe_render_quote(nil, _), do: nil
764 defp maybe_render_quote(quote, opts) do
765 with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
766 false <- Map.get(opts, :do_not_recurse, false),
767 true <- visible_for_user?(quote, opts[:for]),
768 false <- User.blocks?(opts[:for], quoted_user),
769 false <- User.mutes?(opts[:for], quoted_user) do
772 |> Map.put(:activity, quote)
773 |> Map.put(:do_not_recurse, true)
775 render("show.json", opts)
781 defp get_source_text(%{"content" => content} = _source) do
785 defp get_source_text(source) when is_binary(source) do
789 defp get_source_text(_) do
793 defp get_source_content_type(%{"mediaType" => type} = _source) do
797 defp get_source_content_type(_source) do
798 Utils.get_content_type(nil)
801 defp language(%Object{data: %{"contentMap" => contentMap}}) when is_map(contentMap) do
807 defp language(_), do: nil