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)
333 id: to_string(activity.id),
334 uri: object.data["id"],
337 AccountView.render("show.json", %{
341 in_reply_to_id: reply_to && to_string(reply_to.id),
342 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
345 content: content_html,
346 text: opts[:with_source] && object.data["source"],
347 created_at: created_at,
348 reblogs_count: announcement_count,
349 replies_count: object.data["repliesCount"] || 0,
350 favourites_count: like_count,
351 reblogged: reblogged?(activity, opts[:for]),
352 favourited: present?(favorited),
353 bookmarked: present?(bookmarked),
356 sensitive: sensitive,
357 spoiler_text: summary,
358 visibility: get_visibility(object),
359 media_attachments: attachments,
360 poll: render(PollView, "show.json", object: object, for: opts[:for]),
362 tags: build_tags(tags),
363 application: build_application(object.data["generator"]),
365 emojis: build_emojis(object.data["emoji"]),
367 local: activity.local,
368 conversation_id: get_context_id(activity),
369 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
370 content: %{"text/plain" => content_plaintext},
371 spoiler_text: %{"text/plain" => summary},
372 expires_at: expires_at,
373 direct_conversation_id: direct_conversation_id,
374 thread_muted: thread_muted?,
375 emoji_reactions: emoji_reactions,
376 parent_visible: visible_for_user?(reply_to, opts[:for]),
380 source: object.data["source"]
385 def render("show.json", _) do
389 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
390 page_url_data = URI.parse(page_url)
393 if is_binary(rich_media["url"]) do
394 URI.merge(page_url_data, URI.parse(rich_media["url"]))
399 page_url = page_url_data |> to_string
402 if is_binary(rich_media["image"]) do
403 URI.parse(rich_media["image"])
408 image_url = build_image_url(image_url_data, page_url_data)
412 provider_name: page_url_data.host,
413 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
415 image: image_url |> MediaProxy.url(),
416 title: rich_media["title"] || "",
417 description: rich_media["description"] || "",
419 opengraph: rich_media
424 def render("card.json", _), do: nil
426 def render("attachment.json", %{attachment: attachment}) do
427 [attachment_url | _] = attachment["url"]
428 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
429 href = attachment_url["href"] |> MediaProxy.url()
430 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
431 meta = render("attachment_meta.json", %{attachment: attachment})
435 String.contains?(media_type, "image") -> "image"
436 String.contains?(media_type, "video") -> "video"
437 String.contains?(media_type, "audio") -> "audio"
441 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
444 id: to_string(attachment["id"] || hash_id),
447 preview_url: href_preview,
450 description: attachment["name"],
451 pleroma: %{mime_type: media_type},
452 blurhash: attachment["blurhash"]
454 |> Maps.put_if_present(:meta, meta)
457 def render("attachment_meta.json", %{
458 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
460 when is_integer(width) and is_integer(height) do
465 aspect: width / height
470 def render("attachment_meta.json", _), do: nil
472 def render("context.json", %{activity: activity, activities: activities, user: user}) do
473 %{ancestors: ancestors, descendants: descendants} =
476 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
477 |> Map.put_new(:ancestors, [])
478 |> Map.put_new(:descendants, [])
481 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
482 descendants: render("index.json", for: user, activities: descendants, as: :activity)
486 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
487 object = Object.normalize(activity, fetch: false)
489 with nil <- replied_to_activities[object.data["inReplyTo"]] do
490 # If user didn't participate in the thread
491 Activity.get_in_reply_to_activity(activity)
495 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
496 object = Object.normalize(activity, fetch: false)
498 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
499 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
505 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
506 url = object.data["url"] || object.data["id"]
508 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
511 def render_content(object), do: object.data["content"] || ""
514 Builds a dictionary tags.
518 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
519 [{"name": "fediverse", "url": "/tag/fediverse"},
520 {"name": "nextcloud", "url": "/tag/nextcloud"}]
523 @spec build_tags(list(any())) :: list(map())
524 def build_tags(object_tags) when is_list(object_tags) do
526 |> Enum.filter(&is_binary/1)
527 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
530 def build_tags(_), do: []
535 Arguments: `nil` or list tuple of name and url.
541 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
542 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
545 @spec build_emojis(nil | list(tuple())) :: list(map())
546 def build_emojis(nil), do: []
548 def build_emojis(emojis) do
550 |> Enum.map(fn {name, url} ->
551 name = HTML.strip_tags(name)
558 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
562 defp present?(nil), do: false
563 defp present?(false), do: false
564 defp present?(_), do: true
566 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
567 if pinned_at = pinned_objects[object_id] do
568 {true, Utils.to_masto_date(pinned_at)}
574 defp build_emoji_map(emoji, users, url, current_user) do
577 count: length(users),
578 url: MediaProxy.url(url),
579 me: !!(current_user && current_user.ap_id in users)
583 @spec build_application(map() | nil) :: map() | nil
584 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
585 do: %{name: name, website: url}
587 defp build_application(_), do: nil
589 # Workaround for Elixir issue #10771
590 # Avoid applying URI.merge unless necessary
591 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
592 # when Elixir 1.12 is the minimum supported version
593 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
594 defp build_image_url(
595 %URI{scheme: image_scheme, host: image_host} = image_url_data,
596 %URI{} = _page_url_data
598 when not is_nil(image_scheme) and not is_nil(image_host) do
599 image_url_data |> to_string
602 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
603 URI.merge(page_url_data, image_url_data) |> to_string
606 defp build_image_url(_, _), do: nil