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"])
268 reply_to = get_reply_to(activity, opts)
270 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
278 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
279 User.html_filter_policy(opts[:for]),
286 |> Activity.HTML.get_cached_stripped_html_for_activity(
291 summary = object.data["summary"] || ""
293 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
297 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
299 object.data["url"] || object.data["external_url"] || object.data["id"]
302 direct_conversation_id =
303 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
304 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
305 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
306 Activity.direct_conversation_id(activity, for_user)
308 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
317 |> Map.get("reactions", [])
318 |> EmojiReactionController.filter_allowed_users(
320 Map.get(opts, :with_muted, false)
322 |> Stream.map(fn {emoji, users, url} ->
323 build_emoji_map(emoji, users, url, opts[:for])
327 # Status muted state (would do 1 request per status unless user mutes are preloaded)
330 UserRelationship.exists?(
331 get_in(opts, [:relationships, :user_relationships]),
335 fn for_user, user -> User.mutes?(for_user, user) end
338 {pinned?, pinned_at} = pin_data(object, user)
340 quote = Activity.get_quoted_activity_from_object(object)
343 id: to_string(activity.id),
344 uri: object.data["id"],
347 AccountView.render("show.json", %{
351 in_reply_to_id: reply_to && to_string(reply_to.id),
352 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
355 content: content_html,
356 text: opts[:with_source] && object.data["source"],
357 created_at: created_at,
358 reblogs_count: announcement_count,
359 replies_count: object.data["repliesCount"] || 0,
360 favourites_count: like_count,
361 reblogged: reblogged?(activity, opts[:for]),
362 favourited: present?(favorited),
363 bookmarked: present?(bookmarked),
366 sensitive: sensitive,
367 spoiler_text: summary,
368 visibility: get_visibility(object),
369 media_attachments: attachments,
370 poll: render(PollView, "show.json", object: object, for: opts[:for]),
372 tags: build_tags(tags),
373 application: build_application(object.data["generator"]),
375 emojis: build_emojis(object.data["emoji"]),
376 quote_id: if(quote, do: quote.id, else: nil),
377 quote: maybe_render_quote(quote, opts),
378 emoji_reactions: emoji_reactions,
380 local: activity.local,
381 conversation_id: get_context_id(activity),
382 context: object.data["context"],
383 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
384 content: %{"text/plain" => content_plaintext},
385 spoiler_text: %{"text/plain" => summary},
386 expires_at: expires_at,
387 direct_conversation_id: direct_conversation_id,
388 thread_muted: thread_muted?,
389 emoji_reactions: emoji_reactions,
390 parent_visible: visible_for_user?(reply_to, opts[:for]),
394 source: object.data["source"]
399 def render("show.json", _) do
403 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
404 page_url_data = URI.parse(page_url)
407 if is_binary(rich_media["url"]) do
408 URI.merge(page_url_data, URI.parse(rich_media["url"]))
413 page_url = page_url_data |> to_string
416 if is_binary(rich_media["image"]) do
417 URI.parse(rich_media["image"])
422 image_url = build_image_url(image_url_data, page_url_data)
426 provider_name: page_url_data.host,
427 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
429 image: image_url |> MediaProxy.url(),
430 title: rich_media["title"] || "",
431 description: rich_media["description"] || "",
433 opengraph: rich_media
438 def render("card.json", _), do: nil
440 def render("attachment.json", %{attachment: attachment}) do
441 [attachment_url | _] = attachment["url"]
442 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
443 href = attachment_url["href"] |> MediaProxy.url()
444 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
445 meta = render("attachment_meta.json", %{attachment: attachment})
449 String.contains?(media_type, "image") -> "image"
450 String.contains?(media_type, "video") -> "video"
451 String.contains?(media_type, "audio") -> "audio"
455 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
458 id: to_string(attachment["id"] || hash_id),
461 preview_url: href_preview,
464 description: attachment["name"],
465 pleroma: %{mime_type: media_type},
466 blurhash: attachment["blurhash"]
468 |> Maps.put_if_present(:meta, meta)
471 def render("attachment_meta.json", %{
472 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
474 when is_integer(width) and is_integer(height) do
479 aspect: width / height
484 def render("attachment_meta.json", _), do: nil
486 def render("context.json", %{activity: activity, activities: activities, user: user}) do
487 %{ancestors: ancestors, descendants: descendants} =
490 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
491 |> Map.put_new(:ancestors, [])
492 |> Map.put_new(:descendants, [])
495 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
496 descendants: render("index.json", for: user, activities: descendants, as: :activity)
500 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
501 object = Object.normalize(activity, fetch: false)
503 with nil <- replied_to_activities[object.data["inReplyTo"]] do
504 # If user didn't participate in the thread
505 Activity.get_in_reply_to_activity(activity)
509 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
510 object = Object.normalize(activity, fetch: false)
512 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
513 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
519 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
520 url = object.data["url"] || object.data["id"]
522 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
525 def render_content(object), do: object.data["content"] || ""
528 Builds a dictionary tags.
532 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
533 [{"name": "fediverse", "url": "/tag/fediverse"},
534 {"name": "nextcloud", "url": "/tag/nextcloud"}]
537 @spec build_tags(list(any())) :: list(map())
538 def build_tags(object_tags) when is_list(object_tags) do
540 |> Enum.filter(&is_binary/1)
541 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
544 def build_tags(_), do: []
549 Arguments: `nil` or list tuple of name and url.
555 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
556 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
559 @spec build_emojis(nil | list(tuple())) :: list(map())
560 def build_emojis(nil), do: []
562 def build_emojis(emojis) do
564 |> Enum.map(fn {name, url} ->
565 name = HTML.strip_tags(name)
572 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
576 defp present?(nil), do: false
577 defp present?(false), do: false
578 defp present?(_), do: true
580 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
581 if pinned_at = pinned_objects[object_id] do
582 {true, Utils.to_masto_date(pinned_at)}
588 defp build_emoji_map(emoji, users, url, current_user) do
591 count: length(users),
592 url: MediaProxy.url(url),
593 me: !!(current_user && current_user.ap_id in users),
594 account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
598 @spec build_application(map() | nil) :: map() | nil
599 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
600 do: %{name: name, website: url}
602 defp build_application(_), do: nil
604 # Workaround for Elixir issue #10771
605 # Avoid applying URI.merge unless necessary
606 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
607 # when Elixir 1.12 is the minimum supported version
608 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
609 defp build_image_url(
610 %URI{scheme: image_scheme, host: image_host} = image_url_data,
611 %URI{} = _page_url_data
613 when not is_nil(image_scheme) and not is_nil(image_host) do
614 image_url_data |> to_string
617 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
618 URI.merge(page_url_data, image_url_data) |> to_string
621 defp build_image_url(_, _), do: nil
623 defp maybe_render_quote(nil, _), do: nil
625 defp maybe_render_quote(quote, opts) do
626 with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
627 false <- Map.get(opts, :do_not_recurse, false),
628 true <- visible_for_user?(quote, opts[:for]),
629 false <- User.blocks?(opts[:for], quoted_user),
630 false <- User.mutes?(opts[:for], quoted_user) do
633 |> Map.put(:activity, quote)
634 |> Map.put(:do_not_recurse, true)
636 render("show.json", opts)