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 safe_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)
174 id: to_string(activity.id),
175 uri: object.data["id"],
176 url: object.data["id"],
178 AccountView.render("show.json", %{
183 in_reply_to_account_id: nil,
185 content: reblogged[:content] || "",
186 created_at: created_at,
190 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
191 favourited: present?(favorited),
192 bookmarked: present?(bookmarked),
197 visibility: get_visibility(activity),
198 media_attachments: reblogged[:media_attachments] || [],
200 tags: reblogged[:tags] || [],
201 application: build_application(object.data["generator"]),
205 local: activity.local,
211 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
212 object = Object.normalize(activity, fetch: false)
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)
363 id: to_string(activity.id),
364 uri: object.data["id"],
367 AccountView.render("show.json", %{
371 in_reply_to_id: reply_to && to_string(reply_to.id),
372 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
375 content: content_html,
376 text: opts[:with_source] && get_source_text(object.data["source"]),
377 created_at: created_at,
378 edited_at: edited_at,
379 reblogs_count: announcement_count,
380 replies_count: object.data["repliesCount"] || 0,
381 favourites_count: like_count,
382 reblogged: reblogged?(activity, opts[:for]),
383 favourited: present?(favorited),
384 bookmarked: present?(bookmarked),
387 sensitive: sensitive,
388 spoiler_text: summary,
389 visibility: get_visibility(object),
390 media_attachments: attachments,
391 poll: render(PollView, "show.json", object: object, for: opts[:for]),
393 tags: build_tags(tags),
394 application: build_application(object.data["generator"]),
396 emojis: build_emojis(object.data["emoji"]),
397 quote_id: if(quote, do: quote.id, else: nil),
398 quote: maybe_render_quote(quote, opts),
399 emoji_reactions: emoji_reactions,
401 local: activity.local,
402 conversation_id: get_context_id(activity),
403 context: object.data["context"],
404 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
405 content: %{"text/plain" => content_plaintext},
406 spoiler_text: %{"text/plain" => summary},
407 expires_at: expires_at,
408 direct_conversation_id: direct_conversation_id,
409 thread_muted: thread_muted?,
410 emoji_reactions: emoji_reactions,
411 parent_visible: visible_for_user?(reply_to, opts[:for]),
415 source: object.data["source"]
420 def render("show.json", _) do
424 def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
425 object = Object.normalize(activity, fetch: false)
427 hashtags = Object.hashtags(object)
429 user = CommonAPI.get_user(activity.data["actor"])
432 Object.Updater.history_for(object.data)
433 |> Map.get("orderedItems")
434 |> Enum.map(&Map.put(&1, "id", object.data["id"]))
435 |> Enum.map(&%Object{data: &1, id: object.id})
438 [object | past_history]
439 # Mastodon expects the original to be at the first
442 |> Enum.map(fn {object, chrono_order} ->
444 # The history is prepended every time there is a new edit.
445 # In chrono_order, the oldest item is always at 0, and so on.
446 # The chrono_order is an invariant kept between edits.
447 chrono_order: chrono_order,
454 |> Map.put(:as, :item)
455 |> Map.put(:user, user)
456 |> Map.put(:hashtags, hashtags)
458 render_many(history, StatusView, "history_item.json", individual_opts)
466 item: %{object: object, chrono_order: chrono_order},
470 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
472 attachment_data = object.data["attachment"] || []
473 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
475 created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
483 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
484 User.html_filter_policy(opts[:for]),
486 "mastoapi:content:#{chrono_order}"
489 summary = object.data["summary"] || ""
493 AccountView.render("show.json", %{
497 content: content_html,
498 sensitive: sensitive,
499 spoiler_text: summary,
500 created_at: created_at,
501 media_attachments: attachments,
502 emojis: build_emojis(object.data["emoji"]),
503 poll: render(PollView, "show.json", object: object, for: opts[:for])
507 def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
508 object = Object.normalize(activity, fetch: false)
512 text: get_source_text(Map.get(object.data, "source", "")),
513 spoiler_text: Map.get(object.data, "summary", ""),
514 content_type: get_source_content_type(object.data["source"])
518 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
519 page_url_data = URI.parse(page_url)
522 if is_binary(rich_media["url"]) do
523 URI.merge(page_url_data, URI.parse(rich_media["url"]))
528 page_url = page_url_data |> to_string
531 if is_binary(rich_media["image"]) do
532 URI.parse(rich_media["image"])
537 image_url = build_image_url(image_url_data, page_url_data)
541 provider_name: page_url_data.host,
542 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
544 image: image_url |> MediaProxy.url(),
545 title: rich_media["title"] || "",
546 description: rich_media["description"] || "",
548 opengraph: rich_media
553 def render("card.json", _), do: nil
555 def render("attachment.json", %{attachment: attachment}) do
556 [attachment_url | _] = attachment["url"]
557 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
558 href = attachment_url["href"] |> MediaProxy.url()
559 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
560 meta = render("attachment_meta.json", %{attachment: attachment})
564 String.contains?(media_type, "image") -> "image"
565 String.contains?(media_type, "video") -> "video"
566 String.contains?(media_type, "audio") -> "audio"
571 with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
572 {_, %Object{data: _object_data, id: object_id}} <-
573 {:object, Object.get_by_ap_id(ap_id)} do
577 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
578 to_string(attachment["id"] || hash_id)
585 preview_url: href_preview,
588 description: attachment["name"],
589 pleroma: %{mime_type: media_type},
590 blurhash: attachment["blurhash"]
592 |> Maps.put_if_present(:meta, meta)
595 def render("attachment_meta.json", %{
596 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
598 when is_integer(width) and is_integer(height) do
603 aspect: width / height
608 def render("attachment_meta.json", _), do: nil
610 def render("context.json", %{activity: activity, activities: activities, user: user}) do
611 %{ancestors: ancestors, descendants: descendants} =
614 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
615 |> Map.put_new(:ancestors, [])
616 |> Map.put_new(:descendants, [])
619 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
620 descendants: render("index.json", for: user, activities: descendants, as: :activity)
624 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
625 object = Object.normalize(activity, fetch: false)
627 with nil <- replied_to_activities[object.data["inReplyTo"]] do
628 # If user didn't participate in the thread
629 Activity.get_in_reply_to_activity(activity)
633 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
634 object = Object.normalize(activity, fetch: false)
636 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
637 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
643 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
644 url = object.data["url"] || object.data["id"]
646 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
649 def render_content(object), do: object.data["content"] || ""
652 Builds a dictionary tags.
656 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
657 [{"name": "fediverse", "url": "/tag/fediverse"},
658 {"name": "nextcloud", "url": "/tag/nextcloud"}]
661 @spec build_tags(list(any())) :: list(map())
662 def build_tags(object_tags) when is_list(object_tags) do
664 |> Enum.filter(&is_binary/1)
665 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
668 def build_tags(_), do: []
673 Arguments: `nil` or list tuple of name and url.
679 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
680 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
683 @spec build_emojis(nil | list(tuple())) :: list(map())
684 def build_emojis(nil), do: []
686 def build_emojis(emojis) do
688 |> Enum.map(fn {name, url} ->
689 name = HTML.strip_tags(name)
696 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
700 defp present?(nil), do: false
701 defp present?(false), do: false
702 defp present?(_), do: true
704 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
705 if pinned_at = pinned_objects[object_id] do
706 {true, Utils.to_masto_date(pinned_at)}
712 defp build_emoji_map(emoji, users, url, current_user) do
714 name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
715 count: length(users),
716 url: MediaProxy.url(url),
717 me: !!(current_user && current_user.ap_id in users),
718 account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
722 @spec build_application(map() | nil) :: map() | nil
723 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
724 do: %{name: name, website: url}
726 defp build_application(_), do: nil
728 # Workaround for Elixir issue #10771
729 # Avoid applying URI.merge unless necessary
730 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
731 # when Elixir 1.12 is the minimum supported version
732 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
733 defp build_image_url(
734 %URI{scheme: image_scheme, host: image_host} = image_url_data,
735 %URI{} = _page_url_data
737 when not is_nil(image_scheme) and not is_nil(image_host) do
738 image_url_data |> to_string
741 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
742 URI.merge(page_url_data, image_url_data) |> to_string
745 defp build_image_url(_, _), do: nil
747 defp maybe_render_quote(nil, _), do: nil
749 defp maybe_render_quote(quote, opts) do
750 with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
751 false <- Map.get(opts, :do_not_recurse, false),
752 true <- visible_for_user?(quote, opts[:for]),
753 false <- User.blocks?(opts[:for], quoted_user),
754 false <- User.mutes?(opts[:for], quoted_user) do
757 |> Map.put(:activity, quote)
758 |> Map.put(:do_not_recurse, true)
760 render("show.json", opts)
766 defp get_source_text(%{"content" => content} = _source) do
770 defp get_source_text(source) when is_binary(source) do
774 defp get_source_text(_) do
778 defp get_source_content_type(%{"mediaType" => type} = _source) do
782 defp get_source_content_type(_source) do
783 Utils.get_content_type(nil)