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" => context}}) when is_binary(context),
61 do: :erlang.crc32(context)
63 defp get_context_id(_), do: nil
65 # Check if the user reblogged this status
66 defp reblogged?(activity, %User{ap_id: ap_id}) do
67 with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
68 Object.normalize(activity, fetch: false) do
69 ap_id in announcements
75 # False if the user is logged out
76 defp reblogged?(_activity, _user), do: false
78 def render("index.json", opts) do
79 reading_user = opts[:for]
80 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
81 activities = Enum.filter(opts.activities, & &1)
83 # Start fetching rich media before doing anything else, so that later calls to get the cards
84 # only block for timeout in the worst case, as opposed to
85 # length(activities_with_links) * timeout
86 fetch_rich_media_for_activities(activities)
87 replied_to_activities = get_replied_to_activities(activities)
91 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
92 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
93 |> Activity.create_by_object_ap_id()
94 |> Activity.with_preloaded_object(:left)
95 |> Activity.with_preloaded_bookmark(reading_user)
96 |> Activity.with_set_thread_muted_field(reading_user)
101 Map.has_key?(opts, :relationships) ->
104 is_nil(reading_user) ->
105 UserRelationship.view_relationships_option(nil, [])
108 # Note: unresolved users are filtered out
110 (activities ++ parent_activities)
111 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
114 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
119 |> Map.put(:replied_to_activities, replied_to_activities)
120 |> Map.put(:parent_activities, parent_activities)
121 |> Map.put(:relationships, relationships_opt)
123 safe_render_many(activities, StatusView, "show.json", opts)
128 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
130 user = CommonAPI.get_user(activity.data["actor"])
131 created_at = Utils.to_masto_date(activity.data["published"])
132 object = Object.normalize(activity, fetch: false)
134 reblogged_parent_activity =
135 if opts[:parent_activities] do
136 Activity.Queries.find_by_object_ap_id(
137 opts[:parent_activities],
141 Activity.create_by_object_ap_id(object.data["id"])
142 |> Activity.with_preloaded_bookmark(opts[:for])
143 |> Activity.with_set_thread_muted_field(opts[:for])
147 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
148 reblogged = render("show.json", reblog_rendering_opts)
150 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
152 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
156 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
158 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
160 {pinned?, pinned_at} = pin_data(object, user)
163 id: to_string(activity.id),
164 uri: object.data["id"],
165 url: object.data["id"],
167 AccountView.render("show.json", %{
172 in_reply_to_account_id: nil,
174 content: reblogged[:content] || "",
175 created_at: created_at,
179 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
180 favourited: present?(favorited),
181 bookmarked: present?(bookmarked),
186 visibility: get_visibility(activity),
187 media_attachments: reblogged[:media_attachments] || [],
189 tags: reblogged[:tags] || [],
190 application: build_application(object.data["generator"]),
194 local: activity.local,
200 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
201 object = Object.normalize(activity, fetch: false)
203 user = CommonAPI.get_user(activity.data["actor"])
204 user_follower_address = user.follower_address
206 like_count = object.data["like_count"] || 0
207 announcement_count = object.data["announcement_count"] || 0
209 hashtags = Object.hashtags(object)
210 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
212 tags = Object.tags(object)
216 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
217 |> Enum.map(fn tag -> tag["href"] end)
220 (object.data["to"] ++ tag_mentions)
223 Pleroma.Constants.as_public() -> nil
224 ^user_follower_address -> nil
225 ap_id -> User.get_cached_by_ap_id(ap_id)
228 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
230 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
232 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
234 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
237 with true <- client_posted_this_activity,
238 %Oban.Job{scheduled_at: scheduled_at} <-
239 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
247 is_nil(opts[:for]) -> false
248 is_boolean(activity.thread_muted?) -> activity.thread_muted?
249 true -> CommonAPI.thread_muted?(opts[:for], activity)
252 attachment_data = object.data["attachment"] || []
253 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
255 created_at = Utils.to_masto_date(object.data["published"])
257 reply_to = get_reply_to(activity, opts)
259 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
267 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
268 User.html_filter_policy(opts[:for]),
275 |> Activity.HTML.get_cached_stripped_html_for_activity(
280 summary = object.data["summary"] || ""
282 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
286 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
288 object.data["url"] || object.data["external_url"] || object.data["id"]
291 direct_conversation_id =
292 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
293 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
294 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
295 Activity.direct_conversation_id(activity, for_user)
297 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
306 |> Map.get("reactions", [])
307 |> EmojiReactionController.filter_allowed_users(
309 Map.get(opts, :with_muted, false)
311 |> Stream.map(fn {emoji, users, url} ->
312 build_emoji_map(emoji, users, url, opts[:for])
316 # Status muted state (would do 1 request per status unless user mutes are preloaded)
319 UserRelationship.exists?(
320 get_in(opts, [:relationships, :user_relationships]),
324 fn for_user, user -> User.mutes?(for_user, user) end
327 {pinned?, pinned_at} = pin_data(object, user)
329 quote = Activity.get_quoted_activity_from_object(object)
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"]),
365 quote_id: if(quote, do: quote.id, else: nil),
366 quote: maybe_render_quote(quote, opts),
368 local: activity.local,
369 conversation_id: get_context_id(activity),
370 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
371 content: %{"text/plain" => content_plaintext},
372 spoiler_text: %{"text/plain" => summary},
373 expires_at: expires_at,
374 direct_conversation_id: direct_conversation_id,
375 thread_muted: thread_muted?,
376 emoji_reactions: emoji_reactions,
377 parent_visible: visible_for_user?(reply_to, opts[:for]),
381 source: object.data["source"]
386 def render("show.json", _) do
390 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
391 page_url_data = URI.parse(page_url)
394 if is_binary(rich_media["url"]) do
395 URI.merge(page_url_data, URI.parse(rich_media["url"]))
400 page_url = page_url_data |> to_string
403 if is_binary(rich_media["image"]) do
404 URI.parse(rich_media["image"])
409 image_url = build_image_url(image_url_data, page_url_data)
413 provider_name: page_url_data.host,
414 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
416 image: image_url |> MediaProxy.url(),
417 title: rich_media["title"] || "",
418 description: rich_media["description"] || "",
420 opengraph: rich_media
425 def render("card.json", _), do: nil
427 def render("attachment.json", %{attachment: attachment}) do
428 [attachment_url | _] = attachment["url"]
429 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
430 href = attachment_url["href"] |> MediaProxy.url()
431 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
432 meta = render("attachment_meta.json", %{attachment: attachment})
436 String.contains?(media_type, "image") -> "image"
437 String.contains?(media_type, "video") -> "video"
438 String.contains?(media_type, "audio") -> "audio"
442 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
445 id: to_string(attachment["id"] || hash_id),
448 preview_url: href_preview,
451 description: attachment["name"],
452 pleroma: %{mime_type: media_type},
453 blurhash: attachment["blurhash"]
455 |> Maps.put_if_present(:meta, meta)
458 def render("attachment_meta.json", %{
459 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
461 when is_integer(width) and is_integer(height) do
466 aspect: width / height
471 def render("attachment_meta.json", _), do: nil
473 def render("context.json", %{activity: activity, activities: activities, user: user}) do
474 %{ancestors: ancestors, descendants: descendants} =
477 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
478 |> Map.put_new(:ancestors, [])
479 |> Map.put_new(:descendants, [])
482 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
483 descendants: render("index.json", for: user, activities: descendants, as: :activity)
487 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
488 object = Object.normalize(activity, fetch: false)
490 with nil <- replied_to_activities[object.data["inReplyTo"]] do
491 # If user didn't participate in the thread
492 Activity.get_in_reply_to_activity(activity)
496 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
497 object = Object.normalize(activity, fetch: false)
499 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
500 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
506 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
507 url = object.data["url"] || object.data["id"]
509 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
512 def render_content(object), do: object.data["content"] || ""
515 Builds a dictionary tags.
519 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
520 [{"name": "fediverse", "url": "/tag/fediverse"},
521 {"name": "nextcloud", "url": "/tag/nextcloud"}]
524 @spec build_tags(list(any())) :: list(map())
525 def build_tags(object_tags) when is_list(object_tags) do
527 |> Enum.filter(&is_binary/1)
528 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
531 def build_tags(_), do: []
536 Arguments: `nil` or list tuple of name and url.
542 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
543 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
546 @spec build_emojis(nil | list(tuple())) :: list(map())
547 def build_emojis(nil), do: []
549 def build_emojis(emojis) do
551 |> Enum.map(fn {name, url} ->
552 name = HTML.strip_tags(name)
559 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
563 defp present?(nil), do: false
564 defp present?(false), do: false
565 defp present?(_), do: true
567 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
568 if pinned_at = pinned_objects[object_id] do
569 {true, Utils.to_masto_date(pinned_at)}
575 defp build_emoji_map(emoji, users, url, current_user) do
578 count: length(users),
579 url: MediaProxy.url(url),
580 me: !!(current_user && current_user.ap_id in users)
584 @spec build_application(map() | nil) :: map() | nil
585 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
586 do: %{name: name, website: url}
588 defp build_application(_), do: nil
590 # Workaround for Elixir issue #10771
591 # Avoid applying URI.merge unless necessary
592 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
593 # when Elixir 1.12 is the minimum supported version
594 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
595 defp build_image_url(
596 %URI{scheme: image_scheme, host: image_host} = image_url_data,
597 %URI{} = _page_url_data
599 when not is_nil(image_scheme) and not is_nil(image_host) do
600 image_url_data |> to_string
603 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
604 URI.merge(page_url_data, image_url_data) |> to_string
607 defp build_image_url(_, _), do: nil
609 defp maybe_render_quote(nil, _), do: nil
611 defp maybe_render_quote(quote, opts) do
612 if opts[:do_not_recurse] || !visible_for_user?(quote, opts[:for]) do
617 |> Map.put(:activity, quote)
618 |> Map.put(:do_not_recurse, true)
620 render("show.json", opts)