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 with %Object{} = object <- Object.normalize(activity, fetch: false) do
213 user = CommonAPI.get_user(activity.data["actor"])
214 user_follower_address = user.follower_address
216 like_count = object.data["like_count"] || 0
217 announcement_count = object.data["announcement_count"] || 0
219 hashtags = Object.hashtags(object)
220 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
222 tags = Object.tags(object)
226 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
227 |> Enum.map(fn tag -> tag["href"] end)
230 (object.data["to"] ++ tag_mentions)
233 Pleroma.Constants.as_public() -> nil
234 ^user_follower_address -> nil
235 ap_id -> User.get_cached_by_ap_id(ap_id)
238 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
240 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
242 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
244 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
247 with true <- client_posted_this_activity,
248 %Oban.Job{scheduled_at: scheduled_at} <-
249 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
257 is_nil(opts[:for]) -> false
258 is_boolean(activity.thread_muted?) -> activity.thread_muted?
259 true -> CommonAPI.thread_muted?(opts[:for], activity)
262 attachment_data = object.data["attachment"] || []
263 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
265 created_at = Utils.to_masto_date(object.data["published"])
268 with %{"updated" => updated} <- object.data,
269 date <- Utils.to_masto_date(updated),
270 true <- date != "" do
277 reply_to = get_reply_to(activity, opts)
279 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
283 (Object.Updater.history_for(object.data)
284 |> Map.get("orderedItems")
287 # See render("history.json", ...) for more details
288 # Here the implicit index of the current content is 0
289 chrono_order = history_len - 1
297 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
298 User.html_filter_policy(opts[:for]),
300 "mastoapi:content:#{chrono_order}"
305 |> Activity.HTML.get_cached_stripped_html_for_activity(
307 "mastoapi:content:#{chrono_order}"
310 summary = object.data["summary"] || ""
312 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
316 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
318 object.data["url"] || object.data["external_url"] || object.data["id"]
321 direct_conversation_id =
322 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
323 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
324 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
325 Activity.direct_conversation_id(activity, for_user)
327 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
336 |> Map.get("reactions", [])
337 |> EmojiReactionController.filter_allowed_users(
339 Map.get(opts, :with_muted, false)
341 |> Stream.map(fn {emoji, users, url} ->
342 build_emoji_map(emoji, users, url, opts[:for])
346 # Status muted state (would do 1 request per status unless user mutes are preloaded)
349 UserRelationship.exists?(
350 get_in(opts, [:relationships, :user_relationships]),
354 fn for_user, user -> User.mutes?(for_user, user) end
357 {pinned?, pinned_at} = pin_data(object, user)
359 quote = Activity.get_quoted_activity_from_object(object)
362 id: to_string(activity.id),
363 uri: object.data["id"],
366 AccountView.render("show.json", %{
370 in_reply_to_id: reply_to && to_string(reply_to.id),
371 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
374 content: content_html,
375 text: opts[:with_source] && get_source_text(object.data["source"]),
376 created_at: created_at,
377 edited_at: edited_at,
378 reblogs_count: announcement_count,
379 replies_count: object.data["repliesCount"] || 0,
380 favourites_count: like_count,
381 reblogged: reblogged?(activity, opts[:for]),
382 favourited: present?(favorited),
383 bookmarked: present?(bookmarked),
386 sensitive: sensitive,
387 spoiler_text: summary,
388 visibility: get_visibility(object),
389 media_attachments: attachments,
390 poll: render(PollView, "show.json", object: object, for: opts[:for]),
392 tags: build_tags(tags),
393 application: build_application(object.data["generator"]),
395 emojis: build_emojis(object.data["emoji"]),
396 quote_id: if(quote, do: quote.id, else: nil),
397 quote: maybe_render_quote(quote, opts),
398 emoji_reactions: emoji_reactions,
400 local: activity.local,
401 conversation_id: get_context_id(activity),
402 context: object.data["context"],
403 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
404 content: %{"text/plain" => content_plaintext},
405 spoiler_text: %{"text/plain" => summary},
406 expires_at: expires_at,
407 direct_conversation_id: direct_conversation_id,
408 thread_muted: thread_muted?,
409 emoji_reactions: emoji_reactions,
410 parent_visible: visible_for_user?(reply_to, opts[:for]),
414 source: object.data["source"]
422 def render("show.json", _) do
426 def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
427 object = Object.normalize(activity, fetch: false)
429 hashtags = Object.hashtags(object)
431 user = CommonAPI.get_user(activity.data["actor"])
434 Object.Updater.history_for(object.data)
435 |> Map.get("orderedItems")
436 |> Enum.map(&Map.put(&1, "id", object.data["id"]))
437 |> Enum.map(&%Object{data: &1, id: object.id})
440 [object | past_history]
441 # Mastodon expects the original to be at the first
444 |> Enum.map(fn {object, chrono_order} ->
446 # The history is prepended every time there is a new edit.
447 # In chrono_order, the oldest item is always at 0, and so on.
448 # The chrono_order is an invariant kept between edits.
449 chrono_order: chrono_order,
456 |> Map.put(:as, :item)
457 |> Map.put(:user, user)
458 |> Map.put(:hashtags, hashtags)
460 render_many(history, StatusView, "history_item.json", individual_opts)
468 item: %{object: object, chrono_order: chrono_order},
472 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
474 attachment_data = object.data["attachment"] || []
475 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
477 created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
485 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
486 User.html_filter_policy(opts[:for]),
488 "mastoapi:content:#{chrono_order}"
491 summary = object.data["summary"] || ""
495 AccountView.render("show.json", %{
499 content: content_html,
500 sensitive: sensitive,
501 spoiler_text: summary,
502 created_at: created_at,
503 media_attachments: attachments,
504 emojis: build_emojis(object.data["emoji"]),
505 poll: render(PollView, "show.json", object: object, for: opts[:for])
509 def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
510 object = Object.normalize(activity, fetch: false)
514 text: get_source_text(Map.get(object.data, "source", "")),
515 spoiler_text: Map.get(object.data, "summary", ""),
516 content_type: get_source_content_type(object.data["source"])
520 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
521 page_url_data = URI.parse(page_url)
524 if is_binary(rich_media["url"]) do
525 URI.merge(page_url_data, URI.parse(rich_media["url"]))
530 page_url = page_url_data |> to_string
533 if is_binary(rich_media["image"]) do
534 URI.parse(rich_media["image"])
539 image_url = build_image_url(image_url_data, page_url_data)
543 provider_name: page_url_data.host,
544 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
546 image: image_url |> MediaProxy.url(),
547 title: rich_media["title"] || "",
548 description: rich_media["description"] || "",
550 opengraph: rich_media
555 def render("card.json", _), do: nil
557 def render("attachment.json", %{attachment: attachment}) do
558 [attachment_url | _] = attachment["url"]
559 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
560 href = attachment_url["href"] |> MediaProxy.url()
561 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
562 meta = render("attachment_meta.json", %{attachment: attachment})
566 String.contains?(media_type, "image") -> "image"
567 String.contains?(media_type, "video") -> "video"
568 String.contains?(media_type, "audio") -> "audio"
573 with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
574 {_, %Object{data: _object_data, id: object_id}} <-
575 {:object, Object.get_by_ap_id(ap_id)} do
579 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
580 to_string(attachment["id"] || hash_id)
587 preview_url: href_preview,
590 description: attachment["name"],
591 pleroma: %{mime_type: media_type},
592 blurhash: attachment["blurhash"]
594 |> Maps.put_if_present(:meta, meta)
597 def render("attachment_meta.json", %{
598 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
600 when is_integer(width) and is_integer(height) do
605 aspect: width / height
610 def render("attachment_meta.json", _), do: nil
612 def render("context.json", %{activity: activity, activities: activities, user: user}) do
613 %{ancestors: ancestors, descendants: descendants} =
616 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
617 |> Map.put_new(:ancestors, [])
618 |> Map.put_new(:descendants, [])
621 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
622 descendants: render("index.json", for: user, activities: descendants, as: :activity)
626 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
627 object = Object.normalize(activity, fetch: false)
629 with nil <- replied_to_activities[object.data["inReplyTo"]] do
630 # If user didn't participate in the thread
631 Activity.get_in_reply_to_activity(activity)
635 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
636 object = Object.normalize(activity, fetch: false)
638 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
639 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
645 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
646 url = object.data["url"] || object.data["id"]
648 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
651 def render_content(object), do: object.data["content"] || ""
654 Builds a dictionary tags.
658 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
659 [{"name": "fediverse", "url": "/tag/fediverse"},
660 {"name": "nextcloud", "url": "/tag/nextcloud"}]
663 @spec build_tags(list(any())) :: list(map())
664 def build_tags(object_tags) when is_list(object_tags) do
666 |> Enum.filter(&is_binary/1)
667 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
670 def build_tags(_), do: []
675 Arguments: `nil` or list tuple of name and url.
681 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
682 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
685 @spec build_emojis(nil | list(tuple())) :: list(map())
686 def build_emojis(nil), do: []
688 def build_emojis(emojis) do
690 |> Enum.map(fn {name, url} ->
691 name = HTML.strip_tags(name)
698 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
702 defp present?(nil), do: false
703 defp present?(false), do: false
704 defp present?(_), do: true
706 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
707 if pinned_at = pinned_objects[object_id] do
708 {true, Utils.to_masto_date(pinned_at)}
714 defp build_emoji_map(emoji, users, url, current_user) do
716 name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
717 count: length(users),
718 url: MediaProxy.url(url),
719 me: !!(current_user && current_user.ap_id in users),
720 account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
724 @spec build_application(map() | nil) :: map() | nil
725 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
726 do: %{name: name, website: url}
728 defp build_application(_), do: nil
730 # Workaround for Elixir issue #10771
731 # Avoid applying URI.merge unless necessary
732 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
733 # when Elixir 1.12 is the minimum supported version
734 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
735 defp build_image_url(
736 %URI{scheme: image_scheme, host: image_host} = image_url_data,
737 %URI{} = _page_url_data
739 when not is_nil(image_scheme) and not is_nil(image_host) do
740 image_url_data |> to_string
743 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
744 URI.merge(page_url_data, image_url_data) |> to_string
747 defp build_image_url(_, _), do: nil
749 defp maybe_render_quote(nil, _), do: nil
751 defp maybe_render_quote(quote, opts) do
752 with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
753 false <- Map.get(opts, :do_not_recurse, false),
754 true <- visible_for_user?(quote, opts[:for]),
755 false <- User.blocks?(opts[:for], quoted_user),
756 false <- User.mutes?(opts[:for], quoted_user) do
759 |> Map.put(:activity, quote)
760 |> Map.put(:do_not_recurse, true)
762 render("show.json", opts)
768 defp get_source_text(%{"content" => content} = _source) do
772 defp get_source_text(source) when is_binary(source) do
776 defp get_source_text(_) do
780 defp get_source_content_type(%{"mediaType" => type} = _source) do
784 defp get_source_content_type(_source) do
785 Utils.get_content_type(nil)