1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 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]
92 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
93 activities = Enum.filter(opts.activities, & &1)
95 # Start fetching rich media before doing anything else, so that later calls to get the cards
96 # only block for timeout in the worst case, as opposed to
97 # length(activities_with_links) * timeout
98 fetch_rich_media_for_activities(activities)
99 replied_to_activities = get_replied_to_activities(activities)
103 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
104 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
105 |> Activity.create_by_object_ap_id()
106 |> Activity.with_preloaded_object(:left)
107 |> Activity.with_preloaded_bookmark(reading_user)
108 |> Activity.with_set_thread_muted_field(reading_user)
113 Map.has_key?(opts, :relationships) ->
116 is_nil(reading_user) ->
117 UserRelationship.view_relationships_option(nil, [])
120 # Note: unresolved users are filtered out
122 (activities ++ parent_activities)
123 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
126 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
131 |> Map.put(:replied_to_activities, replied_to_activities)
132 |> Map.put(:parent_activities, parent_activities)
133 |> Map.put(:relationships, relationships_opt)
135 safe_render_many(activities, StatusView, "show.json", opts)
140 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
142 user = CommonAPI.get_user(activity.data["actor"])
143 created_at = Utils.to_masto_date(activity.data["published"])
144 object = Object.normalize(activity, fetch: false)
146 reblogged_parent_activity =
147 if opts[:parent_activities] do
148 Activity.Queries.find_by_object_ap_id(
149 opts[:parent_activities],
153 Activity.create_by_object_ap_id(object.data["id"])
154 |> Activity.with_preloaded_bookmark(opts[:for])
155 |> Activity.with_set_thread_muted_field(opts[:for])
159 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
160 reblogged = render("show.json", reblog_rendering_opts)
162 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
164 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
168 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
170 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
172 {pinned?, pinned_at} = pin_data(object, user)
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,
186 content: reblogged[:content] || "",
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 object = Object.normalize(activity, fetch: false)
215 user = CommonAPI.get_user(activity.data["actor"])
216 user_follower_address = user.follower_address
218 like_count = object.data["like_count"] || 0
219 announcement_count = object.data["announcement_count"] || 0
221 hashtags = Object.hashtags(object)
222 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
224 tags = Object.tags(object)
228 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
229 |> Enum.map(fn tag -> tag["href"] end)
232 (object.data["to"] ++ tag_mentions)
235 Pleroma.Constants.as_public() -> nil
236 ^user_follower_address -> nil
237 ap_id -> User.get_cached_by_ap_id(ap_id)
240 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
242 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
244 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
246 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
249 with true <- client_posted_this_activity,
250 %Oban.Job{scheduled_at: scheduled_at} <-
251 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
259 is_nil(opts[:for]) -> false
260 is_boolean(activity.thread_muted?) -> activity.thread_muted?
261 true -> CommonAPI.thread_muted?(opts[:for], activity)
264 attachment_data = object.data["attachment"] || []
265 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
267 created_at = Utils.to_masto_date(object.data["published"])
270 with %{"updated" => updated} <- object.data,
271 date <- Utils.to_masto_date(updated),
272 true <- date != "" do
279 reply_to = get_reply_to(activity, opts)
281 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
285 (Object.Updater.history_for(object.data)
286 |> Map.get("orderedItems")
289 # See render("history.json", ...) for more details
290 # Here the implicit index of the current content is 0
291 chrono_order = history_len - 1
299 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
300 User.html_filter_policy(opts[:for]),
302 "mastoapi:content:#{chrono_order}"
307 |> Activity.HTML.get_cached_stripped_html_for_activity(
309 "mastoapi:content:#{chrono_order}"
312 summary = object.data["summary"] || ""
314 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
318 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
320 object.data["url"] || object.data["external_url"] || object.data["id"]
323 direct_conversation_id =
324 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
325 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
326 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
327 Activity.direct_conversation_id(activity, for_user)
329 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
338 |> Map.get("reactions", [])
339 |> EmojiReactionController.filter_allowed_users(
341 Map.get(opts, :with_muted, false)
343 |> Stream.map(fn {emoji, users} ->
344 build_emoji_map(emoji, users, opts[:for])
348 # Status muted state (would do 1 request per status unless user mutes are preloaded)
351 UserRelationship.exists?(
352 get_in(opts, [:relationships, :user_relationships]),
356 fn for_user, user -> User.mutes?(for_user, user) end
359 {pinned?, pinned_at} = pin_data(object, user)
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"]),
397 local: activity.local,
398 conversation_id: get_context_id(activity),
399 context: object.data["context"],
400 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
401 content: %{"text/plain" => content_plaintext},
402 spoiler_text: %{"text/plain" => summary},
403 expires_at: expires_at,
404 direct_conversation_id: direct_conversation_id,
405 thread_muted: thread_muted?,
406 emoji_reactions: emoji_reactions,
407 parent_visible: visible_for_user?(reply_to, opts[:for]),
413 def render("show.json", _) do
417 def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
418 object = Object.normalize(activity, fetch: false)
420 hashtags = Object.hashtags(object)
422 user = CommonAPI.get_user(activity.data["actor"])
425 Object.Updater.history_for(object.data)
426 |> Map.get("orderedItems")
427 |> Enum.map(&Map.put(&1, "id", object.data["id"]))
428 |> Enum.map(&%Object{data: &1, id: object.id})
431 [object | past_history]
432 # Mastodon expects the original to be at the first
435 |> Enum.map(fn {object, chrono_order} ->
437 # The history is prepended every time there is a new edit.
438 # In chrono_order, the oldest item is always at 0, and so on.
439 # The chrono_order is an invariant kept between edits.
440 chrono_order: chrono_order,
447 |> Map.put(:as, :item)
448 |> Map.put(:user, user)
449 |> Map.put(:hashtags, hashtags)
451 render_many(history, StatusView, "history_item.json", individual_opts)
459 item: %{object: object, chrono_order: chrono_order},
463 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
465 attachment_data = object.data["attachment"] || []
466 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
468 created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
476 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
477 User.html_filter_policy(opts[:for]),
479 "mastoapi:content:#{chrono_order}"
482 summary = object.data["summary"] || ""
486 AccountView.render("show.json", %{
490 content: content_html,
491 sensitive: sensitive,
492 spoiler_text: summary,
493 created_at: created_at,
494 media_attachments: attachments,
495 emojis: build_emojis(object.data["emoji"]),
496 poll: render(PollView, "show.json", object: object, for: opts[:for])
500 def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
501 object = Object.normalize(activity, fetch: false)
505 text: get_source_text(Map.get(object.data, "source", "")),
506 spoiler_text: Map.get(object.data, "summary", ""),
507 content_type: get_source_content_type(object.data["source"])
511 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
512 page_url_data = URI.parse(page_url)
515 if is_binary(rich_media["url"]) do
516 URI.merge(page_url_data, URI.parse(rich_media["url"]))
521 page_url = page_url_data |> to_string
524 if is_binary(rich_media["image"]) do
525 URI.parse(rich_media["image"])
530 image_url = build_image_url(image_url_data, page_url_data)
534 provider_name: page_url_data.host,
535 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
537 image: image_url |> MediaProxy.url(),
538 title: rich_media["title"] || "",
539 description: rich_media["description"] || "",
541 opengraph: rich_media
546 def render("card.json", _), do: nil
548 def render("attachment.json", %{attachment: attachment}) do
549 [attachment_url | _] = attachment["url"]
550 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
551 href = attachment_url["href"] |> MediaProxy.url()
552 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
553 meta = render("attachment_meta.json", %{attachment: attachment})
557 String.contains?(media_type, "image") -> "image"
558 String.contains?(media_type, "video") -> "video"
559 String.contains?(media_type, "audio") -> "audio"
564 with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
565 {_, %Object{data: _object_data, id: object_id}} <-
566 {:object, Object.get_by_ap_id(ap_id)} do
570 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
571 to_string(attachment["id"] || hash_id)
578 preview_url: href_preview,
581 description: attachment["name"],
582 pleroma: %{mime_type: media_type},
583 blurhash: attachment["blurhash"]
585 |> Maps.put_if_present(:meta, meta)
588 def render("attachment_meta.json", %{
589 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
591 when is_integer(width) and is_integer(height) do
596 aspect: width / height
601 def render("attachment_meta.json", _), do: nil
603 def render("context.json", %{activity: activity, activities: activities, user: user}) do
604 %{ancestors: ancestors, descendants: descendants} =
607 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
608 |> Map.put_new(:ancestors, [])
609 |> Map.put_new(:descendants, [])
612 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
613 descendants: render("index.json", for: user, activities: descendants, as: :activity)
617 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
618 object = Object.normalize(activity, fetch: false)
620 with nil <- replied_to_activities[object.data["inReplyTo"]] do
621 # If user didn't participate in the thread
622 Activity.get_in_reply_to_activity(activity)
626 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
627 object = Object.normalize(activity, fetch: false)
629 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
630 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
636 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
637 url = object.data["url"] || object.data["id"]
639 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
642 def render_content(object), do: object.data["content"] || ""
645 Builds a dictionary tags.
649 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
650 [{"name": "fediverse", "url": "/tag/fediverse"},
651 {"name": "nextcloud", "url": "/tag/nextcloud"}]
654 @spec build_tags(list(any())) :: list(map())
655 def build_tags(object_tags) when is_list(object_tags) do
657 |> Enum.filter(&is_binary/1)
658 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
661 def build_tags(_), do: []
666 Arguments: `nil` or list tuple of name and url.
672 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
673 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
676 @spec build_emojis(nil | list(tuple())) :: list(map())
677 def build_emojis(nil), do: []
679 def build_emojis(emojis) do
681 |> Enum.map(fn {name, url} ->
682 name = HTML.strip_tags(name)
689 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
693 defp present?(nil), do: false
694 defp present?(false), do: false
695 defp present?(_), do: true
697 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
698 if pinned_at = pinned_objects[object_id] do
699 {true, Utils.to_masto_date(pinned_at)}
705 defp build_emoji_map(emoji, users, current_user) do
708 count: length(users),
709 me: !!(current_user && current_user.ap_id in users)
713 @spec build_application(map() | nil) :: map() | nil
714 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
715 do: %{name: name, website: url}
717 defp build_application(_), do: nil
719 # Workaround for Elixir issue #10771
720 # Avoid applying URI.merge unless necessary
721 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
722 # when Elixir 1.12 is the minimum supported version
723 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
724 defp build_image_url(
725 %URI{scheme: image_scheme, host: image_host} = image_url_data,
726 %URI{} = _page_url_data
728 when not is_nil(image_scheme) and not is_nil(image_host) do
729 image_url_data |> to_string
732 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
733 URI.merge(page_url_data, image_url_data) |> to_string
736 defp build_image_url(_, _), do: nil
738 defp get_source_text(%{"content" => content} = _source) do
742 defp get_source_text(source) when is_binary(source) do
746 defp get_source_text(_) do
750 defp get_source_content_type(%{"mediaType" => type} = _source) do
754 defp get_source_content_type(_source) do
755 Utils.get_content_type(nil)