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)
230 to_data = if is_nil(object.data["to"]), do: [], else: object.data["to"]
233 (to_data ++ tag_mentions)
236 Pleroma.Constants.as_public() -> nil
237 ^user_follower_address -> nil
238 ap_id -> User.get_cached_by_ap_id(ap_id)
241 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
243 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
245 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
247 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
250 with true <- client_posted_this_activity,
251 %Oban.Job{scheduled_at: scheduled_at} <-
252 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
260 is_nil(opts[:for]) -> false
261 is_boolean(activity.thread_muted?) -> activity.thread_muted?
262 true -> CommonAPI.thread_muted?(opts[:for], activity)
265 attachment_data = object.data["attachment"] || []
266 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
268 created_at = Utils.to_masto_date(object.data["published"])
271 with %{"updated" => updated} <- object.data,
272 date <- Utils.to_masto_date(updated),
273 true <- date != "" do
280 reply_to = get_reply_to(activity, opts)
282 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
286 (Object.Updater.history_for(object.data)
287 |> Map.get("orderedItems")
290 # See render("history.json", ...) for more details
291 # Here the implicit index of the current content is 0
292 chrono_order = history_len - 1
300 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
301 User.html_filter_policy(opts[:for]),
303 "mastoapi:content:#{chrono_order}"
308 |> Activity.HTML.get_cached_stripped_html_for_activity(
310 "mastoapi:content:#{chrono_order}"
313 summary = object.data["summary"] || ""
315 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
319 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
321 object.data["url"] || object.data["external_url"] || object.data["id"]
324 direct_conversation_id =
325 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
326 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
327 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
328 Activity.direct_conversation_id(activity, for_user)
330 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
339 |> Map.get("reactions", [])
340 |> EmojiReactionController.filter_allowed_users(
342 Map.get(opts, :with_muted, false)
344 |> Stream.map(fn {emoji, users, url} ->
345 build_emoji_map(emoji, users, url, opts[:for])
349 # Status muted state (would do 1 request per status unless user mutes are preloaded)
352 UserRelationship.exists?(
353 get_in(opts, [:relationships, :user_relationships]),
357 fn for_user, user -> User.mutes?(for_user, user) end
360 {pinned?, pinned_at} = pin_data(object, user)
362 quote = Activity.get_quoted_activity_from_object(object)
363 lang = language(object)
366 id: to_string(activity.id),
367 uri: object.data["id"],
370 AccountView.render("show.json", %{
374 in_reply_to_id: reply_to && to_string(reply_to.id),
375 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
378 content: content_html,
379 text: opts[:with_source] && get_source_text(object.data["source"]),
380 created_at: created_at,
381 edited_at: edited_at,
382 reblogs_count: announcement_count,
383 replies_count: object.data["repliesCount"] || 0,
384 favourites_count: like_count,
385 reblogged: reblogged?(activity, opts[:for]),
386 favourited: present?(favorited),
387 bookmarked: present?(bookmarked),
390 sensitive: sensitive,
391 spoiler_text: summary,
392 visibility: get_visibility(object),
393 media_attachments: attachments,
394 poll: render(PollView, "show.json", object: object, for: opts[:for]),
396 tags: build_tags(tags),
397 application: build_application(object.data["generator"]),
399 emojis: build_emojis(object.data["emoji"]),
400 quote_id: if(quote, do: quote.id, else: nil),
401 quote: maybe_render_quote(quote, opts),
402 emoji_reactions: emoji_reactions,
404 local: activity.local,
405 conversation_id: get_context_id(activity),
406 context: object.data["context"],
407 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
408 content: %{"text/plain" => content_plaintext},
409 spoiler_text: %{"text/plain" => summary},
410 expires_at: expires_at,
411 direct_conversation_id: direct_conversation_id,
412 thread_muted: thread_muted?,
413 emoji_reactions: emoji_reactions,
414 parent_visible: visible_for_user?(reply_to, opts[:for]),
418 source: object.data["source"]
426 def render("show.json", _) do
430 def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
431 object = Object.normalize(activity, fetch: false)
433 hashtags = Object.hashtags(object)
435 user = CommonAPI.get_user(activity.data["actor"])
438 Object.Updater.history_for(object.data)
439 |> Map.get("orderedItems")
440 |> Enum.map(&Map.put(&1, "id", object.data["id"]))
441 |> Enum.map(&%Object{data: &1, id: object.id})
444 [object | past_history]
445 # Mastodon expects the original to be at the first
448 |> Enum.map(fn {object, chrono_order} ->
450 # The history is prepended every time there is a new edit.
451 # In chrono_order, the oldest item is always at 0, and so on.
452 # The chrono_order is an invariant kept between edits.
453 chrono_order: chrono_order,
460 |> Map.put(:as, :item)
461 |> Map.put(:user, user)
462 |> Map.put(:hashtags, hashtags)
464 render_many(history, StatusView, "history_item.json", individual_opts)
472 item: %{object: object, chrono_order: chrono_order},
476 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
478 attachment_data = object.data["attachment"] || []
479 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
481 created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
489 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
490 User.html_filter_policy(opts[:for]),
492 "mastoapi:content:#{chrono_order}"
495 summary = object.data["summary"] || ""
499 AccountView.render("show.json", %{
503 content: content_html,
504 sensitive: sensitive,
505 spoiler_text: summary,
506 created_at: created_at,
507 media_attachments: attachments,
508 emojis: build_emojis(object.data["emoji"]),
509 poll: render(PollView, "show.json", object: object, for: opts[:for])
513 def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
514 object = Object.normalize(activity, fetch: false)
518 text: get_source_text(Map.get(object.data, "source", "")),
519 spoiler_text: Map.get(object.data, "summary", ""),
520 content_type: get_source_content_type(object.data["source"])
524 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
525 page_url_data = URI.parse(page_url)
528 if is_binary(rich_media["url"]) do
529 URI.merge(page_url_data, URI.parse(rich_media["url"]))
534 page_url = page_url_data |> to_string
537 if is_binary(rich_media["image"]) do
538 URI.parse(rich_media["image"])
543 image_url = build_image_url(image_url_data, page_url_data)
547 provider_name: page_url_data.host,
548 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
550 image: image_url |> MediaProxy.url(),
551 title: rich_media["title"] || "",
552 description: rich_media["description"] || "",
554 opengraph: rich_media
559 def render("card.json", _), do: nil
561 def render("attachment.json", %{attachment: attachment}) do
562 [attachment_url | _] = attachment["url"]
563 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
564 href = attachment_url["href"] |> MediaProxy.url()
565 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
566 meta = render("attachment_meta.json", %{attachment: attachment})
570 String.contains?(media_type, "image") -> "image"
571 String.contains?(media_type, "video") -> "video"
572 String.contains?(media_type, "audio") -> "audio"
577 with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
578 {_, %Object{data: _object_data, id: object_id}} <-
579 {:object, Object.get_by_ap_id(ap_id)} do
583 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
584 to_string(attachment["id"] || hash_id)
591 preview_url: href_preview,
594 description: attachment["name"],
595 pleroma: %{mime_type: media_type},
596 blurhash: attachment["blurhash"]
598 |> Maps.put_if_present(:meta, meta)
601 def render("attachment_meta.json", %{
602 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
604 when is_integer(width) and is_integer(height) do
609 aspect: width / height
614 def render("attachment_meta.json", _), do: nil
616 def render("context.json", %{activity: activity, activities: activities, user: user}) do
617 %{ancestors: ancestors, descendants: descendants} =
620 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
621 |> Map.put_new(:ancestors, [])
622 |> Map.put_new(:descendants, [])
625 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
626 descendants: render("index.json", for: user, activities: descendants, as: :activity)
630 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
631 object = Object.normalize(activity, fetch: false)
633 with nil <- replied_to_activities[object.data["inReplyTo"]] do
634 # If user didn't participate in the thread
635 Activity.get_in_reply_to_activity(activity)
639 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
640 object = Object.normalize(activity, fetch: false)
642 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
643 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
649 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
650 url = object.data["url"] || object.data["id"]
652 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
655 def render_content(object), do: object.data["content"] || ""
658 Builds a dictionary tags.
662 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
663 [{"name": "fediverse", "url": "/tag/fediverse"},
664 {"name": "nextcloud", "url": "/tag/nextcloud"}]
667 @spec build_tags(list(any())) :: list(map())
668 def build_tags(object_tags) when is_list(object_tags) do
670 |> Enum.filter(&is_binary/1)
671 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
674 def build_tags(_), do: []
679 Arguments: `nil` or list tuple of name and url.
685 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
686 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
689 @spec build_emojis(nil | list(tuple())) :: list(map())
690 def build_emojis(nil), do: []
692 def build_emojis(emojis) do
694 |> Enum.map(fn {name, url} ->
695 name = HTML.strip_tags(name)
702 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
706 defp present?(nil), do: false
707 defp present?(false), do: false
708 defp present?(_), do: true
710 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
711 if pinned_at = pinned_objects[object_id] do
712 {true, Utils.to_masto_date(pinned_at)}
718 defp build_emoji_map(emoji, users, url, current_user) do
720 name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
721 count: length(users),
722 url: MediaProxy.url(url),
723 me: !!(current_user && current_user.ap_id in users),
724 account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
728 @spec build_application(map() | nil) :: map() | nil
729 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
730 do: %{name: name, website: url}
732 defp build_application(_), do: nil
734 # Workaround for Elixir issue #10771
735 # Avoid applying URI.merge unless necessary
736 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
737 # when Elixir 1.12 is the minimum supported version
738 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
739 defp build_image_url(
740 %URI{scheme: image_scheme, host: image_host} = image_url_data,
741 %URI{} = _page_url_data
743 when not is_nil(image_scheme) and not is_nil(image_host) do
744 image_url_data |> to_string
747 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
748 URI.merge(page_url_data, image_url_data) |> to_string
751 defp build_image_url(_, _), do: nil
753 defp maybe_render_quote(nil, _), do: nil
755 defp maybe_render_quote(quote, opts) do
756 with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
757 false <- Map.get(opts, :do_not_recurse, false),
758 true <- visible_for_user?(quote, opts[:for]),
759 false <- User.blocks?(opts[:for], quoted_user),
760 false <- User.mutes?(opts[:for], quoted_user) do
763 |> Map.put(:activity, quote)
764 |> Map.put(:do_not_recurse, true)
766 render("show.json", opts)
772 defp get_source_text(%{"content" => content} = _source) do
776 defp get_source_text(source) when is_binary(source) do
780 defp get_source_text(_) do
784 defp get_source_content_type(%{"mediaType" => type} = _source) do
788 defp get_source_content_type(_source) do
789 Utils.get_content_type(nil)
792 defp language(%Object{data: %{"contentMap" => contentMap}}) when is_map(contentMap) do
798 defp language(_), do: nil