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]
84 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
85 activities = Enum.filter(opts.activities, & &1)
87 # Start fetching rich media before doing anything else, so that later calls to get the cards
88 # only block for timeout in the worst case, as opposed to
89 # length(activities_with_links) * timeout
90 fetch_rich_media_for_activities(activities)
91 replied_to_activities = get_replied_to_activities(activities)
95 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
96 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
97 |> Activity.create_by_object_ap_id()
98 |> Activity.with_preloaded_object(:left)
99 |> Activity.with_preloaded_bookmark(reading_user)
100 |> Activity.with_set_thread_muted_field(reading_user)
105 Map.has_key?(opts, :relationships) ->
108 is_nil(reading_user) ->
109 UserRelationship.view_relationships_option(nil, [])
112 # Note: unresolved users are filtered out
114 (activities ++ parent_activities)
115 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
118 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
123 |> Map.put(:replied_to_activities, replied_to_activities)
124 |> Map.put(:parent_activities, parent_activities)
125 |> Map.put(:relationships, relationships_opt)
127 safe_render_many(activities, StatusView, "show.json", opts)
132 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
134 user = CommonAPI.get_user(activity.data["actor"])
135 created_at = Utils.to_masto_date(activity.data["published"])
136 object = Object.normalize(activity, fetch: false)
138 reblogged_parent_activity =
139 if opts[:parent_activities] do
140 Activity.Queries.find_by_object_ap_id(
141 opts[:parent_activities],
145 Activity.create_by_object_ap_id(object.data["id"])
146 |> Activity.with_preloaded_bookmark(opts[:for])
147 |> Activity.with_set_thread_muted_field(opts[:for])
151 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
152 reblogged = render("show.json", reblog_rendering_opts)
154 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
156 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
160 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
162 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
164 {pinned?, pinned_at} = pin_data(object, user)
167 id: to_string(activity.id),
168 uri: object.data["id"],
169 url: object.data["id"],
171 AccountView.render("show.json", %{
176 in_reply_to_account_id: nil,
178 content: reblogged[:content] || "",
179 created_at: created_at,
183 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
184 favourited: present?(favorited),
185 bookmarked: present?(bookmarked),
190 visibility: get_visibility(activity),
191 media_attachments: reblogged[:media_attachments] || [],
193 tags: reblogged[:tags] || [],
194 application: build_application(object.data["generator"]),
198 local: activity.local,
204 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
205 object = Object.normalize(activity, fetch: false)
207 user = CommonAPI.get_user(activity.data["actor"])
208 user_follower_address = user.follower_address
210 like_count = object.data["like_count"] || 0
211 announcement_count = object.data["announcement_count"] || 0
213 hashtags = Object.hashtags(object)
214 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
216 tags = Object.tags(object)
220 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
221 |> Enum.map(fn tag -> tag["href"] end)
224 (object.data["to"] ++ tag_mentions)
227 Pleroma.Constants.as_public() -> nil
228 ^user_follower_address -> nil
229 ap_id -> User.get_cached_by_ap_id(ap_id)
232 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
234 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
236 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
238 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
241 with true <- client_posted_this_activity,
242 %Oban.Job{scheduled_at: scheduled_at} <-
243 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
251 is_nil(opts[:for]) -> false
252 is_boolean(activity.thread_muted?) -> activity.thread_muted?
253 true -> CommonAPI.thread_muted?(opts[:for], activity)
256 attachment_data = object.data["attachment"] || []
257 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
259 created_at = Utils.to_masto_date(object.data["published"])
261 reply_to = get_reply_to(activity, opts)
263 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
271 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
272 User.html_filter_policy(opts[:for]),
279 |> Activity.HTML.get_cached_stripped_html_for_activity(
284 summary = object.data["summary"] || ""
286 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
290 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
292 object.data["url"] || object.data["external_url"] || object.data["id"]
295 direct_conversation_id =
296 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
297 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
298 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
299 Activity.direct_conversation_id(activity, for_user)
301 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
310 |> Map.get("reactions", [])
311 |> EmojiReactionController.filter_allowed_users(
313 Map.get(opts, :with_muted, false)
315 |> Stream.map(fn {emoji, users} ->
316 build_emoji_map(emoji, users, opts[:for])
320 # Status muted state (would do 1 request per status unless user mutes are preloaded)
323 UserRelationship.exists?(
324 get_in(opts, [:relationships, :user_relationships]),
328 fn for_user, user -> User.mutes?(for_user, user) end
331 {pinned?, pinned_at} = pin_data(object, user)
334 id: to_string(activity.id),
335 uri: object.data["id"],
338 AccountView.render("show.json", %{
342 in_reply_to_id: reply_to && to_string(reply_to.id),
343 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
346 content: content_html,
347 text: opts[:with_source] && object.data["source"],
348 created_at: created_at,
349 reblogs_count: announcement_count,
350 replies_count: object.data["repliesCount"] || 0,
351 favourites_count: like_count,
352 reblogged: reblogged?(activity, opts[:for]),
353 favourited: present?(favorited),
354 bookmarked: present?(bookmarked),
357 sensitive: sensitive,
358 spoiler_text: summary,
359 visibility: get_visibility(object),
360 media_attachments: attachments,
361 poll: render(PollView, "show.json", object: object, for: opts[:for]),
363 tags: build_tags(tags),
364 application: build_application(object.data["generator"]),
366 emojis: build_emojis(object.data["emoji"]),
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]),
383 def render("show.json", _) do
387 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
388 page_url_data = URI.parse(page_url)
391 if is_binary(rich_media["url"]) do
392 URI.merge(page_url_data, URI.parse(rich_media["url"]))
397 page_url = page_url_data |> to_string
400 if is_binary(rich_media["image"]) do
401 URI.parse(rich_media["image"])
406 image_url = build_image_url(image_url_data, page_url_data)
410 provider_name: page_url_data.host,
411 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
413 image: image_url |> MediaProxy.url(),
414 title: rich_media["title"] || "",
415 description: rich_media["description"] || "",
417 opengraph: rich_media
422 def render("card.json", _), do: nil
424 def render("attachment.json", %{attachment: attachment}) do
425 [attachment_url | _] = attachment["url"]
426 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
427 href = attachment_url["href"] |> MediaProxy.url()
428 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
429 meta = render("attachment_meta.json", %{attachment: attachment})
433 String.contains?(media_type, "image") -> "image"
434 String.contains?(media_type, "video") -> "video"
435 String.contains?(media_type, "audio") -> "audio"
439 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
442 id: to_string(attachment["id"] || hash_id),
445 preview_url: href_preview,
448 description: attachment["name"],
449 pleroma: %{mime_type: media_type},
450 blurhash: attachment["blurhash"]
452 |> Maps.put_if_present(:meta, meta)
455 def render("attachment_meta.json", %{
456 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
458 when is_integer(width) and is_integer(height) do
463 aspect: width / height
468 def render("attachment_meta.json", _), do: nil
470 def render("context.json", %{activity: activity, activities: activities, user: user}) do
471 %{ancestors: ancestors, descendants: descendants} =
474 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
475 |> Map.put_new(:ancestors, [])
476 |> Map.put_new(:descendants, [])
479 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
480 descendants: render("index.json", for: user, activities: descendants, as: :activity)
484 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
485 object = Object.normalize(activity, fetch: false)
487 with nil <- replied_to_activities[object.data["inReplyTo"]] do
488 # If user didn't participate in the thread
489 Activity.get_in_reply_to_activity(activity)
493 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
494 object = Object.normalize(activity, fetch: false)
496 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
497 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
503 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
504 url = object.data["url"] || object.data["id"]
506 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
509 def render_content(object), do: object.data["content"] || ""
512 Builds a dictionary tags.
516 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
517 [{"name": "fediverse", "url": "/tag/fediverse"},
518 {"name": "nextcloud", "url": "/tag/nextcloud"}]
521 @spec build_tags(list(any())) :: list(map())
522 def build_tags(object_tags) when is_list(object_tags) do
524 |> Enum.filter(&is_binary/1)
525 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
528 def build_tags(_), do: []
533 Arguments: `nil` or list tuple of name and url.
539 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
540 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
543 @spec build_emojis(nil | list(tuple())) :: list(map())
544 def build_emojis(nil), do: []
546 def build_emojis(emojis) do
548 |> Enum.map(fn {name, url} ->
549 name = HTML.strip_tags(name)
556 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
560 defp present?(nil), do: false
561 defp present?(false), do: false
562 defp present?(_), do: true
564 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
565 if pinned_at = pinned_objects[object_id] do
566 {true, Utils.to_masto_date(pinned_at)}
572 defp build_emoji_map(emoji, users, current_user) do
575 count: length(users),
576 me: !!(current_user && current_user.ap_id in users)
580 @spec build_application(map() | nil) :: map() | nil
581 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
582 do: %{name: name, website: url}
584 defp build_application(_), do: nil
586 # Workaround for Elixir issue #10771
587 # Avoid applying URI.merge unless necessary
588 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
589 # when Elixir 1.12 is the minimum supported version
590 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
591 defp build_image_url(
592 %URI{scheme: image_scheme, host: image_host} = image_url_data,
593 %URI{} = _page_url_data
595 when not is_nil(image_scheme) and not is_nil(image_host) do
596 image_url_data |> to_string
599 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
600 URI.merge(page_url_data, image_url_data) |> to_string
603 defp build_image_url(_, _), do: nil