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 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
63 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
64 do: Utils.context_to_conversation_id(context)
66 defp get_context_id(_), do: nil
68 # Check if the user reblogged this status
69 defp reblogged?(activity, %User{ap_id: ap_id}) do
70 with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
71 Object.normalize(activity, fetch: false) do
72 ap_id in announcements
78 # False if the user is logged out
79 defp reblogged?(_activity, _user), do: false
81 def render("index.json", opts) do
82 reading_user = opts[:for]
83 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
84 activities = Enum.filter(opts.activities, & &1)
86 # Start fetching rich media before doing anything else, so that later calls to get the cards
87 # only block for timeout in the worst case, as opposed to
88 # length(activities_with_links) * timeout
89 fetch_rich_media_for_activities(activities)
90 replied_to_activities = get_replied_to_activities(activities)
94 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
95 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
96 |> Activity.create_by_object_ap_id()
97 |> Activity.with_preloaded_object(:left)
98 |> Activity.with_preloaded_bookmark(reading_user)
99 |> Activity.with_set_thread_muted_field(reading_user)
104 Map.has_key?(opts, :relationships) ->
107 is_nil(reading_user) ->
108 UserRelationship.view_relationships_option(nil, [])
111 # Note: unresolved users are filtered out
113 (activities ++ parent_activities)
114 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
117 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
122 |> Map.put(:replied_to_activities, replied_to_activities)
123 |> Map.put(:parent_activities, parent_activities)
124 |> Map.put(:relationships, relationships_opt)
126 safe_render_many(activities, StatusView, "show.json", opts)
131 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
133 user = CommonAPI.get_user(activity.data["actor"])
134 created_at = Utils.to_masto_date(activity.data["published"])
135 object = Object.normalize(activity, fetch: false)
137 reblogged_parent_activity =
138 if opts[:parent_activities] do
139 Activity.Queries.find_by_object_ap_id(
140 opts[:parent_activities],
144 Activity.create_by_object_ap_id(object.data["id"])
145 |> Activity.with_preloaded_bookmark(opts[:for])
146 |> Activity.with_set_thread_muted_field(opts[:for])
150 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
151 reblogged = render("show.json", reblog_rendering_opts)
153 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
155 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
159 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
161 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
163 {pinned?, pinned_at} = pin_data(object, user)
166 id: to_string(activity.id),
167 uri: object.data["id"],
168 url: object.data["id"],
170 AccountView.render("show.json", %{
175 in_reply_to_account_id: nil,
177 content: reblogged[:content] || "",
178 created_at: created_at,
182 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
183 favourited: present?(favorited),
184 bookmarked: present?(bookmarked),
189 visibility: get_visibility(activity),
190 media_attachments: reblogged[:media_attachments] || [],
192 tags: reblogged[:tags] || [],
193 application: build_application(object.data["generator"]),
197 local: activity.local,
203 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
204 object = Object.normalize(activity, fetch: false)
206 user = CommonAPI.get_user(activity.data["actor"])
207 user_follower_address = user.follower_address
209 like_count = object.data["like_count"] || 0
210 announcement_count = object.data["announcement_count"] || 0
212 hashtags = Object.hashtags(object)
213 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
215 tags = Object.tags(object)
219 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
220 |> Enum.map(fn tag -> tag["href"] end)
223 (object.data["to"] ++ tag_mentions)
226 Pleroma.Constants.as_public() -> nil
227 ^user_follower_address -> nil
228 ap_id -> User.get_cached_by_ap_id(ap_id)
231 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
233 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
235 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
237 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
240 with true <- client_posted_this_activity,
241 %Oban.Job{scheduled_at: scheduled_at} <-
242 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
250 is_nil(opts[:for]) -> false
251 is_boolean(activity.thread_muted?) -> activity.thread_muted?
252 true -> CommonAPI.thread_muted?(opts[:for], activity)
255 attachment_data = object.data["attachment"] || []
256 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
258 created_at = Utils.to_masto_date(object.data["published"])
260 reply_to = get_reply_to(activity, opts)
262 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
270 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
271 User.html_filter_policy(opts[:for]),
278 |> Activity.HTML.get_cached_stripped_html_for_activity(
283 summary = object.data["summary"] || ""
285 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
289 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
291 object.data["url"] || object.data["external_url"] || object.data["id"]
294 direct_conversation_id =
295 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
296 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
297 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
298 Activity.direct_conversation_id(activity, for_user)
300 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
309 |> Map.get("reactions", [])
310 |> EmojiReactionController.filter_allowed_users(
312 Map.get(opts, :with_muted, false)
314 |> Stream.map(fn {emoji, users, url} ->
315 build_emoji_map(emoji, users, url, opts[:for])
319 # Status muted state (would do 1 request per status unless user mutes are preloaded)
322 UserRelationship.exists?(
323 get_in(opts, [:relationships, :user_relationships]),
327 fn for_user, user -> User.mutes?(for_user, user) end
330 {pinned?, pinned_at} = pin_data(object, user)
332 quote = Activity.get_quoted_activity_from_object(object)
335 id: to_string(activity.id),
336 uri: object.data["id"],
339 AccountView.render("show.json", %{
343 in_reply_to_id: reply_to && to_string(reply_to.id),
344 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
347 content: content_html,
348 text: opts[:with_source] && object.data["source"],
349 created_at: created_at,
350 reblogs_count: announcement_count,
351 replies_count: object.data["repliesCount"] || 0,
352 favourites_count: like_count,
353 reblogged: reblogged?(activity, opts[:for]),
354 favourited: present?(favorited),
355 bookmarked: present?(bookmarked),
358 sensitive: sensitive,
359 spoiler_text: summary,
360 visibility: get_visibility(object),
361 media_attachments: attachments,
362 poll: render(PollView, "show.json", object: object, for: opts[:for]),
364 tags: build_tags(tags),
365 application: build_application(object.data["generator"]),
367 emojis: build_emojis(object.data["emoji"]),
368 quote_id: if(quote, do: quote.id, else: nil),
369 quote: maybe_render_quote(quote, opts),
371 local: activity.local,
372 conversation_id: get_context_id(activity),
373 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
374 content: %{"text/plain" => content_plaintext},
375 spoiler_text: %{"text/plain" => summary},
376 expires_at: expires_at,
377 direct_conversation_id: direct_conversation_id,
378 thread_muted: thread_muted?,
379 emoji_reactions: emoji_reactions,
380 parent_visible: visible_for_user?(reply_to, opts[:for]),
384 source: object.data["source"]
389 def render("show.json", _) do
393 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
394 page_url_data = URI.parse(page_url)
397 if is_binary(rich_media["url"]) do
398 URI.merge(page_url_data, URI.parse(rich_media["url"]))
403 page_url = page_url_data |> to_string
406 if is_binary(rich_media["image"]) do
407 URI.parse(rich_media["image"])
412 image_url = build_image_url(image_url_data, page_url_data)
416 provider_name: page_url_data.host,
417 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
419 image: image_url |> MediaProxy.url(),
420 title: rich_media["title"] || "",
421 description: rich_media["description"] || "",
423 opengraph: rich_media
428 def render("card.json", _), do: nil
430 def render("attachment.json", %{attachment: attachment}) do
431 [attachment_url | _] = attachment["url"]
432 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
433 href = attachment_url["href"] |> MediaProxy.url()
434 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
435 meta = render("attachment_meta.json", %{attachment: attachment})
439 String.contains?(media_type, "image") -> "image"
440 String.contains?(media_type, "video") -> "video"
441 String.contains?(media_type, "audio") -> "audio"
445 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
448 id: to_string(attachment["id"] || hash_id),
451 preview_url: href_preview,
454 description: attachment["name"],
455 pleroma: %{mime_type: media_type},
456 blurhash: attachment["blurhash"]
458 |> Maps.put_if_present(:meta, meta)
461 def render("attachment_meta.json", %{
462 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
464 when is_integer(width) and is_integer(height) do
469 aspect: width / height
474 def render("attachment_meta.json", _), do: nil
476 def render("context.json", %{activity: activity, activities: activities, user: user}) do
477 %{ancestors: ancestors, descendants: descendants} =
480 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
481 |> Map.put_new(:ancestors, [])
482 |> Map.put_new(:descendants, [])
485 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
486 descendants: render("index.json", for: user, activities: descendants, as: :activity)
490 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
491 object = Object.normalize(activity, fetch: false)
493 with nil <- replied_to_activities[object.data["inReplyTo"]] do
494 # If user didn't participate in the thread
495 Activity.get_in_reply_to_activity(activity)
499 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
500 object = Object.normalize(activity, fetch: false)
502 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
503 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
509 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
510 url = object.data["url"] || object.data["id"]
512 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
515 def render_content(object), do: object.data["content"] || ""
518 Builds a dictionary tags.
522 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
523 [{"name": "fediverse", "url": "/tag/fediverse"},
524 {"name": "nextcloud", "url": "/tag/nextcloud"}]
527 @spec build_tags(list(any())) :: list(map())
528 def build_tags(object_tags) when is_list(object_tags) do
530 |> Enum.filter(&is_binary/1)
531 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
534 def build_tags(_), do: []
539 Arguments: `nil` or list tuple of name and url.
545 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
546 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
549 @spec build_emojis(nil | list(tuple())) :: list(map())
550 def build_emojis(nil), do: []
552 def build_emojis(emojis) do
554 |> Enum.map(fn {name, url} ->
555 name = HTML.strip_tags(name)
562 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
566 defp present?(nil), do: false
567 defp present?(false), do: false
568 defp present?(_), do: true
570 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
571 if pinned_at = pinned_objects[object_id] do
572 {true, Utils.to_masto_date(pinned_at)}
578 defp build_emoji_map(emoji, users, url, current_user) do
581 count: length(users),
582 url: MediaProxy.url(url),
583 me: !!(current_user && current_user.ap_id in users)
587 @spec build_application(map() | nil) :: map() | nil
588 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
589 do: %{name: name, website: url}
591 defp build_application(_), do: nil
593 # Workaround for Elixir issue #10771
594 # Avoid applying URI.merge unless necessary
595 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
596 # when Elixir 1.12 is the minimum supported version
597 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
598 defp build_image_url(
599 %URI{scheme: image_scheme, host: image_host} = image_url_data,
600 %URI{} = _page_url_data
602 when not is_nil(image_scheme) and not is_nil(image_host) do
603 image_url_data |> to_string
606 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
607 URI.merge(page_url_data, image_url_data) |> to_string
610 defp build_image_url(_, _), do: nil
612 defp maybe_render_quote(nil, _), do: nil
614 defp maybe_render_quote(quote, opts) do
615 if opts[:do_not_recurse] || !visible_for_user?(quote, opts[:for]) do
620 |> Map.put(:activity, quote)
621 |> Map.put(:do_not_recurse, true)
623 render("show.json", opts)