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 defp reblogged?(activity, user) do
69 object = Object.normalize(activity, fetch: false) || %{}
70 present?(user && user.ap_id in (object.data["announcements"] || []))
73 def render("index.json", opts) do
74 reading_user = opts[:for]
76 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
77 activities = Enum.filter(opts.activities, & &1)
79 # Start fetching rich media before doing anything else, so that later calls to get the cards
80 # only block for timeout in the worst case, as opposed to
81 # length(activities_with_links) * timeout
82 fetch_rich_media_for_activities(activities)
83 replied_to_activities = get_replied_to_activities(activities)
87 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
88 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
89 |> Activity.create_by_object_ap_id()
90 |> Activity.with_preloaded_object(:left)
91 |> Activity.with_preloaded_bookmark(reading_user)
92 |> Activity.with_set_thread_muted_field(reading_user)
97 Map.has_key?(opts, :relationships) ->
100 is_nil(reading_user) ->
101 UserRelationship.view_relationships_option(nil, [])
104 # Note: unresolved users are filtered out
106 (activities ++ parent_activities)
107 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
110 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
115 |> Map.put(:replied_to_activities, replied_to_activities)
116 |> Map.put(:parent_activities, parent_activities)
117 |> Map.put(:relationships, relationships_opt)
119 safe_render_many(activities, StatusView, "show.json", opts)
124 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
126 user = CommonAPI.get_user(activity.data["actor"])
127 created_at = Utils.to_masto_date(activity.data["published"])
128 object = Object.normalize(activity, fetch: false)
130 reblogged_parent_activity =
131 if opts[:parent_activities] do
132 Activity.Queries.find_by_object_ap_id(
133 opts[:parent_activities],
137 Activity.create_by_object_ap_id(object.data["id"])
138 |> Activity.with_preloaded_bookmark(opts[:for])
139 |> Activity.with_set_thread_muted_field(opts[:for])
143 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
144 reblogged = render("show.json", reblog_rendering_opts)
146 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
148 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
152 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
154 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
156 {pinned?, pinned_at} = pin_data(object, user)
159 id: to_string(activity.id),
160 uri: object.data["id"],
161 url: object.data["id"],
163 AccountView.render("show.json", %{
168 in_reply_to_account_id: nil,
170 content: reblogged[:content] || "",
171 created_at: created_at,
175 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
176 favourited: present?(favorited),
177 bookmarked: present?(bookmarked),
182 visibility: get_visibility(activity),
183 media_attachments: reblogged[:media_attachments] || [],
185 tags: reblogged[:tags] || [],
186 application: build_application(object.data["generator"]),
190 local: activity.local,
196 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
197 object = Object.normalize(activity, fetch: false)
199 user = CommonAPI.get_user(activity.data["actor"])
200 user_follower_address = user.follower_address
202 like_count = object.data["like_count"] || 0
203 announcement_count = object.data["announcement_count"] || 0
205 hashtags = Object.hashtags(object)
206 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
208 tags = Object.tags(object)
212 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
213 |> Enum.map(fn tag -> tag["href"] end)
216 (object.data["to"] ++ tag_mentions)
219 Pleroma.Constants.as_public() -> nil
220 ^user_follower_address -> nil
221 ap_id -> User.get_cached_by_ap_id(ap_id)
224 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
226 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
228 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
230 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
233 with true <- client_posted_this_activity,
234 %Oban.Job{scheduled_at: scheduled_at} <-
235 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
243 is_nil(opts[:for]) -> false
244 is_boolean(activity.thread_muted?) -> activity.thread_muted?
245 true -> CommonAPI.thread_muted?(opts[:for], activity)
248 attachment_data = object.data["attachment"] || []
249 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
251 created_at = Utils.to_masto_date(object.data["published"])
253 reply_to = get_reply_to(activity, opts)
255 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
263 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
264 User.html_filter_policy(opts[:for]),
271 |> Activity.HTML.get_cached_stripped_html_for_activity(
276 summary = object.data["summary"] || ""
278 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
282 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
284 object.data["url"] || object.data["external_url"] || object.data["id"]
287 direct_conversation_id =
288 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
289 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
290 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
291 Activity.direct_conversation_id(activity, for_user)
293 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
302 |> Map.get("reactions", [])
303 |> EmojiReactionController.filter_allowed_users(
305 Map.get(opts, :with_muted, false)
307 |> Stream.map(fn {emoji, users} ->
308 build_emoji_map(emoji, users, opts[:for])
312 # Status muted state (would do 1 request per status unless user mutes are preloaded)
315 UserRelationship.exists?(
316 get_in(opts, [:relationships, :user_relationships]),
320 fn for_user, user -> User.mutes?(for_user, user) end
323 {pinned?, pinned_at} = pin_data(object, user)
326 id: to_string(activity.id),
327 uri: object.data["id"],
330 AccountView.render("show.json", %{
334 in_reply_to_id: reply_to && to_string(reply_to.id),
335 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
338 content: content_html,
339 text: opts[:with_source] && object.data["source"],
340 created_at: created_at,
341 reblogs_count: announcement_count,
342 replies_count: object.data["repliesCount"] || 0,
343 favourites_count: like_count,
344 reblogged: reblogged?(activity, opts[:for]),
345 favourited: present?(favorited),
346 bookmarked: present?(bookmarked),
349 sensitive: sensitive,
350 spoiler_text: summary,
351 visibility: get_visibility(object),
352 media_attachments: attachments,
353 poll: render(PollView, "show.json", object: object, for: opts[:for]),
355 tags: build_tags(tags),
356 application: build_application(object.data["generator"]),
358 emojis: build_emojis(object.data["emoji"]),
360 local: activity.local,
361 conversation_id: get_context_id(activity),
362 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
363 content: %{"text/plain" => content_plaintext},
364 spoiler_text: %{"text/plain" => summary},
365 expires_at: expires_at,
366 direct_conversation_id: direct_conversation_id,
367 thread_muted: thread_muted?,
368 emoji_reactions: emoji_reactions,
369 parent_visible: visible_for_user?(reply_to, opts[:for]),
375 def render("show.json", _) do
379 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
380 page_url_data = URI.parse(page_url)
383 if is_binary(rich_media["url"]) do
384 URI.merge(page_url_data, URI.parse(rich_media["url"]))
389 page_url = page_url_data |> to_string
392 if is_binary(rich_media["image"]) do
393 URI.parse(rich_media["image"])
398 image_url = build_image_url(image_url_data, page_url_data)
402 provider_name: page_url_data.host,
403 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
405 image: image_url |> MediaProxy.url(),
406 title: rich_media["title"] || "",
407 description: rich_media["description"] || "",
409 opengraph: rich_media
414 def render("card.json", _), do: nil
416 def render("attachment.json", %{attachment: attachment}) do
417 [attachment_url | _] = attachment["url"]
418 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
419 href = attachment_url["href"] |> MediaProxy.url()
420 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
421 meta = render("attachment_meta.json", %{attachment: attachment})
425 String.contains?(media_type, "image") -> "image"
426 String.contains?(media_type, "video") -> "video"
427 String.contains?(media_type, "audio") -> "audio"
431 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
434 id: to_string(attachment["id"] || hash_id),
437 preview_url: href_preview,
440 description: attachment["name"],
441 pleroma: %{mime_type: media_type},
442 blurhash: attachment["blurhash"]
444 |> Maps.put_if_present(:meta, meta)
447 def render("attachment_meta.json", %{
448 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
450 when is_integer(width) and is_integer(height) do
455 aspect: width / height
460 def render("attachment_meta.json", _), do: nil
462 def render("context.json", %{activity: activity, activities: activities, user: user}) do
463 %{ancestors: ancestors, descendants: descendants} =
466 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
467 |> Map.put_new(:ancestors, [])
468 |> Map.put_new(:descendants, [])
471 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
472 descendants: render("index.json", for: user, activities: descendants, as: :activity)
476 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
477 object = Object.normalize(activity, fetch: false)
479 with nil <- replied_to_activities[object.data["inReplyTo"]] do
480 # If user didn't participate in the thread
481 Activity.get_in_reply_to_activity(activity)
485 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
486 object = Object.normalize(activity, fetch: false)
488 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
489 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
495 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
496 url = object.data["url"] || object.data["id"]
498 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
501 def render_content(object), do: object.data["content"] || ""
504 Builds a dictionary tags.
508 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
509 [{"name": "fediverse", "url": "/tag/fediverse"},
510 {"name": "nextcloud", "url": "/tag/nextcloud"}]
513 @spec build_tags(list(any())) :: list(map())
514 def build_tags(object_tags) when is_list(object_tags) do
516 |> Enum.filter(&is_binary/1)
517 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
520 def build_tags(_), do: []
525 Arguments: `nil` or list tuple of name and url.
531 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
532 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
535 @spec build_emojis(nil | list(tuple())) :: list(map())
536 def build_emojis(nil), do: []
538 def build_emojis(emojis) do
540 |> Enum.map(fn {name, url} ->
541 name = HTML.strip_tags(name)
548 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
552 defp present?(nil), do: false
553 defp present?(false), do: false
554 defp present?(_), do: true
556 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
557 if pinned_at = pinned_objects[object_id] do
558 {true, Utils.to_masto_date(pinned_at)}
564 defp build_emoji_map(emoji, users, current_user) do
567 count: length(users),
568 me: !!(current_user && current_user.ap_id in users)
572 @spec build_application(map() | nil) :: map() | nil
573 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
574 do: %{name: name, website: url}
576 defp build_application(_), do: nil
578 # Workaround for Elixir issue #10771
579 # Avoid applying URI.merge unless necessary
580 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
581 # when Elixir 1.12 is the minimum supported version
582 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
583 defp build_image_url(
584 %URI{scheme: image_scheme, host: image_host} = image_url_data,
585 %URI{} = _page_url_data
587 when not is_nil(image_scheme) and not is_nil(image_host) do
588 image_url_data |> to_string
591 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
592 URI.merge(page_url_data, image_url_data) |> to_string
595 defp build_image_url(_, _), do: nil