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 id: to_string(activity.id),
333 uri: object.data["id"],
336 AccountView.render("show.json", %{
340 in_reply_to_id: reply_to && to_string(reply_to.id),
341 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
344 content: content_html,
345 text: opts[:with_source] && object.data["source"],
346 created_at: created_at,
347 reblogs_count: announcement_count,
348 replies_count: object.data["repliesCount"] || 0,
349 favourites_count: like_count,
350 reblogged: reblogged?(activity, opts[:for]),
351 favourited: present?(favorited),
352 bookmarked: present?(bookmarked),
355 sensitive: sensitive,
356 spoiler_text: summary,
357 visibility: get_visibility(object),
358 media_attachments: attachments,
359 poll: render(PollView, "show.json", object: object, for: opts[:for]),
361 tags: build_tags(tags),
362 application: build_application(object.data["generator"]),
364 emojis: build_emojis(object.data["emoji"]),
366 local: activity.local,
367 conversation_id: get_context_id(activity),
368 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
369 content: %{"text/plain" => content_plaintext},
370 spoiler_text: %{"text/plain" => summary},
371 expires_at: expires_at,
372 direct_conversation_id: direct_conversation_id,
373 thread_muted: thread_muted?,
374 emoji_reactions: emoji_reactions,
375 parent_visible: visible_for_user?(reply_to, opts[:for]),
379 source: object.data["source"]
384 def render("show.json", _) do
388 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
389 page_url_data = URI.parse(page_url)
392 if is_binary(rich_media["url"]) do
393 URI.merge(page_url_data, URI.parse(rich_media["url"]))
398 page_url = page_url_data |> to_string
401 if is_binary(rich_media["image"]) do
402 URI.parse(rich_media["image"])
407 image_url = build_image_url(image_url_data, page_url_data)
411 provider_name: page_url_data.host,
412 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
414 image: image_url |> MediaProxy.url(),
415 title: rich_media["title"] || "",
416 description: rich_media["description"] || "",
418 opengraph: rich_media
423 def render("card.json", _), do: nil
425 def render("attachment.json", %{attachment: attachment}) do
426 [attachment_url | _] = attachment["url"]
427 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
428 href = attachment_url["href"] |> MediaProxy.url()
429 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
430 meta = render("attachment_meta.json", %{attachment: attachment})
434 String.contains?(media_type, "image") -> "image"
435 String.contains?(media_type, "video") -> "video"
436 String.contains?(media_type, "audio") -> "audio"
440 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
443 id: to_string(attachment["id"] || hash_id),
446 preview_url: href_preview,
449 description: attachment["name"],
450 pleroma: %{mime_type: media_type},
451 blurhash: attachment["blurhash"]
453 |> Maps.put_if_present(:meta, meta)
456 def render("attachment_meta.json", %{
457 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
459 when is_integer(width) and is_integer(height) do
464 aspect: width / height
469 def render("attachment_meta.json", _), do: nil
471 def render("context.json", %{activity: activity, activities: activities, user: user}) do
472 %{ancestors: ancestors, descendants: descendants} =
475 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
476 |> Map.put_new(:ancestors, [])
477 |> Map.put_new(:descendants, [])
480 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
481 descendants: render("index.json", for: user, activities: descendants, as: :activity)
485 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
486 object = Object.normalize(activity, fetch: false)
488 with nil <- replied_to_activities[object.data["inReplyTo"]] do
489 # If user didn't participate in the thread
490 Activity.get_in_reply_to_activity(activity)
494 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
495 object = Object.normalize(activity, fetch: false)
497 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
498 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
504 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
505 url = object.data["url"] || object.data["id"]
507 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
510 def render_content(object), do: object.data["content"] || ""
513 Builds a dictionary tags.
517 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
518 [{"name": "fediverse", "url": "/tag/fediverse"},
519 {"name": "nextcloud", "url": "/tag/nextcloud"}]
522 @spec build_tags(list(any())) :: list(map())
523 def build_tags(object_tags) when is_list(object_tags) do
525 |> Enum.filter(&is_binary/1)
526 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
529 def build_tags(_), do: []
534 Arguments: `nil` or list tuple of name and url.
540 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
541 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
544 @spec build_emojis(nil | list(tuple())) :: list(map())
545 def build_emojis(nil), do: []
547 def build_emojis(emojis) do
549 |> Enum.map(fn {name, url} ->
550 name = HTML.strip_tags(name)
557 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
561 defp present?(nil), do: false
562 defp present?(false), do: false
563 defp present?(_), do: true
565 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
566 if pinned_at = pinned_objects[object_id] do
567 {true, Utils.to_masto_date(pinned_at)}
573 defp build_emoji_map(emoji, users, url, current_user) do
576 count: length(users),
577 url: MediaProxy.url(url),
578 me: !!(current_user && current_user.ap_id in users)
582 @spec build_application(map() | nil) :: map() | nil
583 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
584 do: %{name: name, website: url}
586 defp build_application(_), do: nil
588 # Workaround for Elixir issue #10771
589 # Avoid applying URI.merge unless necessary
590 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
591 # when Elixir 1.12 is the minimum supported version
592 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
593 defp build_image_url(
594 %URI{scheme: image_scheme, host: image_host} = image_url_data,
595 %URI{} = _page_url_data
597 when not is_nil(image_scheme) and not is_nil(image_host) do
598 image_url_data |> to_string
601 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
602 URI.merge(page_url_data, image_url_data) |> to_string
605 defp build_image_url(_, _), do: nil