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
15 alias Pleroma.UserRelationship
16 alias Pleroma.Web.CommonAPI
17 alias Pleroma.Web.CommonAPI.Utils
18 alias Pleroma.Web.MastodonAPI.AccountView
19 alias Pleroma.Web.MastodonAPI.PollView
20 alias Pleroma.Web.MastodonAPI.StatusView
21 alias Pleroma.Web.MediaProxy
22 alias Pleroma.Web.PleromaAPI.EmojiReactionController
24 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
26 # This is a naive way to do this, just spawning a process per activity
27 # to fetch the preview. However it should be fine considering
28 # pagination is restricted to 40 activities at a time
29 defp fetch_rich_media_for_activities(activities) do
30 Enum.each(activities, fn activity ->
32 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
37 # TODO: Add cached version.
38 defp get_replied_to_activities([]), do: %{}
40 defp get_replied_to_activities(activities) do
43 %{data: %{"type" => "Create"}} = activity ->
44 object = Object.normalize(activity, fetch: false)
45 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
51 |> Activity.create_by_object_ap_id_with_object()
53 |> Enum.reduce(%{}, fn activity, acc ->
54 object = Object.normalize(activity, fetch: false)
55 if object, do: Map.put(acc, object.data["id"], activity), else: acc
59 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
62 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
63 do: Utils.context_to_conversation_id(context)
65 defp get_context_id(_), do: nil
67 defp reblogged?(activity, user) do
68 object = Object.normalize(activity, fetch: false) || %{}
69 present?(user && user.ap_id in (object.data["announcements"] || []))
72 def render("index.json", opts) do
73 reading_user = opts[:for]
75 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
76 activities = Enum.filter(opts.activities, & &1)
78 # Start fetching rich media before doing anything else, so that later calls to get the cards
79 # only block for timeout in the worst case, as opposed to
80 # length(activities_with_links) * timeout
81 fetch_rich_media_for_activities(activities)
82 replied_to_activities = get_replied_to_activities(activities)
86 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
87 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
88 |> Activity.create_by_object_ap_id()
89 |> Activity.with_preloaded_object(:left)
90 |> Activity.with_preloaded_bookmark(reading_user)
91 |> Activity.with_set_thread_muted_field(reading_user)
96 Map.has_key?(opts, :relationships) ->
99 is_nil(reading_user) ->
100 UserRelationship.view_relationships_option(nil, [])
103 # Note: unresolved users are filtered out
105 (activities ++ parent_activities)
106 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
109 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
114 |> Map.put(:replied_to_activities, replied_to_activities)
115 |> Map.put(:parent_activities, parent_activities)
116 |> Map.put(:relationships, relationships_opt)
118 safe_render_many(activities, StatusView, "show.json", opts)
123 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
125 user = CommonAPI.get_user(activity.data["actor"])
126 created_at = Utils.to_masto_date(activity.data["published"])
127 object = Object.normalize(activity, fetch: false)
129 reblogged_parent_activity =
130 if opts[:parent_activities] do
131 Activity.Queries.find_by_object_ap_id(
132 opts[:parent_activities],
136 Activity.create_by_object_ap_id(object.data["id"])
137 |> Activity.with_preloaded_bookmark(opts[:for])
138 |> Activity.with_set_thread_muted_field(opts[:for])
142 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
143 reblogged = render("show.json", reblog_rendering_opts)
145 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
147 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
151 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
153 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
155 {pinned?, pinned_at} = pin_data(object, user)
158 id: to_string(activity.id),
159 uri: object.data["id"],
160 url: object.data["id"],
162 AccountView.render("show.json", %{
167 in_reply_to_account_id: nil,
169 content: reblogged[:content] || "",
170 created_at: created_at,
174 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
175 favourited: present?(favorited),
176 bookmarked: present?(bookmarked),
181 visibility: get_visibility(activity),
182 media_attachments: reblogged[:media_attachments] || [],
184 tags: reblogged[:tags] || [],
185 application: build_application(object.data["generator"]),
189 local: activity.local,
195 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
196 object = Object.normalize(activity, fetch: false)
198 user = CommonAPI.get_user(activity.data["actor"])
199 user_follower_address = user.follower_address
201 like_count = object.data["like_count"] || 0
202 announcement_count = object.data["announcement_count"] || 0
204 hashtags = Object.hashtags(object)
205 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
207 tags = Object.tags(object)
211 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
212 |> Enum.map(fn tag -> tag["href"] end)
215 (object.data["to"] ++ tag_mentions)
218 Pleroma.Constants.as_public() -> nil
219 ^user_follower_address -> nil
220 ap_id -> User.get_cached_by_ap_id(ap_id)
223 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
225 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
227 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
229 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
232 with true <- client_posted_this_activity,
233 %Oban.Job{scheduled_at: scheduled_at} <-
234 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
242 is_nil(opts[:for]) -> false
243 is_boolean(activity.thread_muted?) -> activity.thread_muted?
244 true -> CommonAPI.thread_muted?(opts[:for], activity)
247 attachment_data = object.data["attachment"] || []
248 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
250 created_at = Utils.to_masto_date(object.data["published"])
252 reply_to = get_reply_to(activity, opts)
254 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
262 |> HTML.get_cached_scrubbed_html_for_activity(
263 User.html_filter_policy(opts[:for]),
270 |> HTML.get_cached_stripped_html_for_activity(
275 summary = object.data["summary"] || ""
277 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
281 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
283 object.data["url"] || object.data["external_url"] || object.data["id"]
286 direct_conversation_id =
287 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
288 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
289 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
290 Activity.direct_conversation_id(activity, for_user)
292 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
301 |> Map.get("reactions", [])
302 |> EmojiReactionController.filter_allowed_users(
304 Map.get(opts, :with_muted, false)
306 |> Stream.map(fn {emoji, users} ->
307 build_emoji_map(emoji, users, opts[:for])
311 # Status muted state (would do 1 request per status unless user mutes are preloaded)
314 UserRelationship.exists?(
315 get_in(opts, [:relationships, :user_relationships]),
319 fn for_user, user -> User.mutes?(for_user, user) end
322 {pinned?, pinned_at} = pin_data(object, user)
325 id: to_string(activity.id),
326 uri: object.data["id"],
329 AccountView.render("show.json", %{
333 in_reply_to_id: reply_to && to_string(reply_to.id),
334 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
337 content: content_html,
338 text: opts[:with_source] && object.data["source"],
339 created_at: created_at,
340 reblogs_count: announcement_count,
341 replies_count: object.data["repliesCount"] || 0,
342 favourites_count: like_count,
343 reblogged: reblogged?(activity, opts[:for]),
344 favourited: present?(favorited),
345 bookmarked: present?(bookmarked),
348 sensitive: sensitive,
349 spoiler_text: summary,
350 visibility: get_visibility(object),
351 media_attachments: attachments,
352 poll: render(PollView, "show.json", object: object, for: opts[:for]),
354 tags: build_tags(tags),
355 application: build_application(object.data["generator"]),
357 emojis: build_emojis(object.data["emoji"]),
359 local: activity.local,
360 conversation_id: get_context_id(activity),
361 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
362 content: %{"text/plain" => content_plaintext},
363 spoiler_text: %{"text/plain" => summary},
364 expires_at: expires_at,
365 direct_conversation_id: direct_conversation_id,
366 thread_muted: thread_muted?,
367 emoji_reactions: emoji_reactions,
368 parent_visible: visible_for_user?(reply_to, opts[:for]),
374 def render("show.json", _) do
378 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
379 page_url_data = URI.parse(page_url)
382 if is_binary(rich_media["url"]) do
383 URI.merge(page_url_data, URI.parse(rich_media["url"]))
388 page_url = page_url_data |> to_string
391 if is_binary(rich_media["image"]) do
392 URI.parse(rich_media["image"])
397 image_url = build_image_url(image_url_data, page_url_data)
401 provider_name: page_url_data.host,
402 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
404 image: image_url |> MediaProxy.url(),
405 title: rich_media["title"] || "",
406 description: rich_media["description"] || "",
408 opengraph: rich_media
413 def render("card.json", _), do: nil
415 def render("attachment.json", %{attachment: attachment}) do
416 [attachment_url | _] = attachment["url"]
417 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
418 href = attachment_url["href"] |> MediaProxy.url()
419 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
423 String.contains?(media_type, "image") -> "image"
424 String.contains?(media_type, "video") -> "video"
425 String.contains?(media_type, "audio") -> "audio"
429 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
432 id: to_string(attachment["id"] || hash_id),
435 preview_url: href_preview,
438 description: attachment["name"],
439 pleroma: %{mime_type: media_type},
440 blurhash: attachment["blurhash"]
444 def render("context.json", %{activity: activity, activities: activities, user: user}) do
445 %{ancestors: ancestors, descendants: descendants} =
448 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
449 |> Map.put_new(:ancestors, [])
450 |> Map.put_new(:descendants, [])
453 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
454 descendants: render("index.json", for: user, activities: descendants, as: :activity)
458 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
459 object = Object.normalize(activity, fetch: false)
461 with nil <- replied_to_activities[object.data["inReplyTo"]] do
462 # If user didn't participate in the thread
463 Activity.get_in_reply_to_activity(activity)
467 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
468 object = Object.normalize(activity, fetch: false)
470 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
471 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
477 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
478 url = object.data["url"] || object.data["id"]
480 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
483 def render_content(object), do: object.data["content"] || ""
486 Builds a dictionary tags.
490 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
491 [{"name": "fediverse", "url": "/tag/fediverse"},
492 {"name": "nextcloud", "url": "/tag/nextcloud"}]
495 @spec build_tags(list(any())) :: list(map())
496 def build_tags(object_tags) when is_list(object_tags) do
498 |> Enum.filter(&is_binary/1)
499 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"})
502 def build_tags(_), do: []
507 Arguments: `nil` or list tuple of name and url.
513 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
514 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
517 @spec build_emojis(nil | list(tuple())) :: list(map())
518 def build_emojis(nil), do: []
520 def build_emojis(emojis) do
522 |> Enum.map(fn {name, url} ->
523 name = HTML.strip_tags(name)
530 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
534 defp present?(nil), do: false
535 defp present?(false), do: false
536 defp present?(_), do: true
538 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
539 if pinned_at = pinned_objects[object_id] do
540 {true, Utils.to_masto_date(pinned_at)}
546 defp build_emoji_map(emoji, users, current_user) do
549 count: length(users),
550 me: !!(current_user && current_user.ap_id in users)
554 @spec build_application(map() | nil) :: map() | nil
555 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
556 do: %{name: name, website: url}
558 defp build_application(_), do: nil
560 # Workaround for Elixir issue #10771
561 # Avoid applying URI.merge unless necessary
562 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
563 # when Elixir 1.12 is the minimum supported version
564 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
565 defp build_image_url(
566 %URI{scheme: image_scheme, host: image_host} = image_url_data,
567 %URI{} = _page_url_data
569 when not is_nil(image_scheme) and not is_nil(image_host) do
570 image_url_data |> to_string
573 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
574 URI.merge(page_url_data, image_url_data) |> to_string
577 defp build_image_url(_, _), do: nil