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)
156 id: to_string(activity.id),
157 uri: object.data["id"],
158 url: object.data["id"],
160 AccountView.render("show.json", %{
165 in_reply_to_account_id: nil,
167 content: reblogged[:content] || "",
168 created_at: created_at,
172 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
173 favourited: present?(favorited),
174 bookmarked: present?(bookmarked),
176 pinned: pinned?(activity, user),
179 visibility: get_visibility(activity),
180 media_attachments: reblogged[:media_attachments] || [],
182 tags: reblogged[:tags] || [],
183 application: build_application(object.data["generator"]),
187 local: activity.local
192 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
193 object = Object.normalize(activity, fetch: false)
195 user = CommonAPI.get_user(activity.data["actor"])
196 user_follower_address = user.follower_address
198 like_count = object.data["like_count"] || 0
199 announcement_count = object.data["announcement_count"] || 0
201 hashtags = Object.hashtags(object)
202 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
204 tags = Object.tags(object)
208 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
209 |> Enum.map(fn tag -> tag["href"] end)
212 (object.data["to"] ++ tag_mentions)
215 Pleroma.Constants.as_public() -> nil
216 ^user_follower_address -> nil
217 ap_id -> User.get_cached_by_ap_id(ap_id)
220 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
222 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
224 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
226 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
229 with true <- client_posted_this_activity,
230 %Oban.Job{scheduled_at: scheduled_at} <-
231 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
239 is_nil(opts[:for]) -> false
240 is_boolean(activity.thread_muted?) -> activity.thread_muted?
241 true -> CommonAPI.thread_muted?(opts[:for], activity)
244 attachment_data = object.data["attachment"] || []
245 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
247 created_at = Utils.to_masto_date(object.data["published"])
249 reply_to = get_reply_to(activity, opts)
251 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
259 |> HTML.get_cached_scrubbed_html_for_activity(
260 User.html_filter_policy(opts[:for]),
267 |> HTML.get_cached_stripped_html_for_activity(
272 summary = object.data["summary"] || ""
274 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
278 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
280 object.data["url"] || object.data["external_url"] || object.data["id"]
283 direct_conversation_id =
284 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
285 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
286 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
287 Activity.direct_conversation_id(activity, for_user)
289 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
298 |> Map.get("reactions", [])
299 |> EmojiReactionController.filter_allowed_users(
301 Map.get(opts, :with_muted, false)
303 |> Stream.map(fn {emoji, users} ->
304 build_emoji_map(emoji, users, opts[:for])
308 # Status muted state (would do 1 request per status unless user mutes are preloaded)
311 UserRelationship.exists?(
312 get_in(opts, [:relationships, :user_relationships]),
316 fn for_user, user -> User.mutes?(for_user, user) end
320 id: to_string(activity.id),
321 uri: object.data["id"],
324 AccountView.render("show.json", %{
328 in_reply_to_id: reply_to && to_string(reply_to.id),
329 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
332 content: content_html,
333 text: opts[:with_source] && object.data["source"],
334 created_at: created_at,
335 reblogs_count: announcement_count,
336 replies_count: object.data["repliesCount"] || 0,
337 favourites_count: like_count,
338 reblogged: reblogged?(activity, opts[:for]),
339 favourited: present?(favorited),
340 bookmarked: present?(bookmarked),
342 pinned: pinned?(activity, user),
343 sensitive: sensitive,
344 spoiler_text: summary,
345 visibility: get_visibility(object),
346 media_attachments: attachments,
347 poll: render(PollView, "show.json", object: object, for: opts[:for]),
349 tags: build_tags(tags),
350 application: build_application(object.data["generator"]),
352 emojis: build_emojis(object.data["emoji"]),
354 local: activity.local,
355 conversation_id: get_context_id(activity),
356 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
357 content: %{"text/plain" => content_plaintext},
358 spoiler_text: %{"text/plain" => summary},
359 expires_at: expires_at,
360 direct_conversation_id: direct_conversation_id,
361 thread_muted: thread_muted?,
362 emoji_reactions: emoji_reactions,
363 parent_visible: visible_for_user?(reply_to, opts[:for])
368 def render("show.json", _) do
372 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
373 page_url_data = URI.parse(page_url)
376 if is_binary(rich_media["url"]) do
377 URI.merge(page_url_data, URI.parse(rich_media["url"]))
382 page_url = page_url_data |> to_string
385 if is_binary(rich_media["image"]) do
386 URI.parse(rich_media["image"])
391 image_url = build_image_url(image_url_data, page_url_data)
395 provider_name: page_url_data.host,
396 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
398 image: image_url |> MediaProxy.url(),
399 title: rich_media["title"] || "",
400 description: rich_media["description"] || "",
402 opengraph: rich_media
407 def render("card.json", _), do: nil
409 def render("attachment.json", %{attachment: attachment}) do
410 [attachment_url | _] = attachment["url"]
411 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
412 href = attachment_url["href"] |> MediaProxy.url()
413 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
417 String.contains?(media_type, "image") -> "image"
418 String.contains?(media_type, "video") -> "video"
419 String.contains?(media_type, "audio") -> "audio"
423 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
426 id: to_string(attachment["id"] || hash_id),
429 preview_url: href_preview,
432 description: attachment["name"],
433 pleroma: %{mime_type: media_type},
434 blurhash: attachment["blurhash"]
438 def render("context.json", %{activity: activity, activities: activities, user: user}) do
439 %{ancestors: ancestors, descendants: descendants} =
442 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
443 |> Map.put_new(:ancestors, [])
444 |> Map.put_new(:descendants, [])
447 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
448 descendants: render("index.json", for: user, activities: descendants, as: :activity)
452 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
453 object = Object.normalize(activity, fetch: false)
455 with nil <- replied_to_activities[object.data["inReplyTo"]] do
456 # If user didn't participate in the thread
457 Activity.get_in_reply_to_activity(activity)
461 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
462 object = Object.normalize(activity, fetch: false)
464 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
465 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
471 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
472 url = object.data["url"] || object.data["id"]
474 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
477 def render_content(object), do: object.data["content"] || ""
480 Builds a dictionary tags.
484 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
485 [{"name": "fediverse", "url": "/tag/fediverse"},
486 {"name": "nextcloud", "url": "/tag/nextcloud"}]
489 @spec build_tags(list(any())) :: list(map())
490 def build_tags(object_tags) when is_list(object_tags) do
492 |> Enum.filter(&is_binary/1)
493 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"})
496 def build_tags(_), do: []
501 Arguments: `nil` or list tuple of name and url.
507 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
508 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
511 @spec build_emojis(nil | list(tuple())) :: list(map())
512 def build_emojis(nil), do: []
514 def build_emojis(emojis) do
516 |> Enum.map(fn {name, url} ->
517 name = HTML.strip_tags(name)
524 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
528 defp present?(nil), do: false
529 defp present?(false), do: false
530 defp present?(_), do: true
532 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
533 do: id in pinned_activities
535 defp build_emoji_map(emoji, users, current_user) do
538 count: length(users),
539 me: !!(current_user && current_user.ap_id in users)
543 @spec build_application(map() | nil) :: map() | nil
544 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
545 do: %{name: name, website: url}
547 defp build_application(_), do: nil
549 # Workaround for Elixir issue #10771
550 # Avoid applying URI.merge unless necessary
551 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
552 # when Elixir 1.12 is the minimum supported version
553 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
554 defp build_image_url(
555 %URI{scheme: image_scheme, host: image_host} = image_url_data,
556 %URI{} = _page_url_data
558 when not is_nil(image_scheme) and not is_nil(image_host) do
559 image_url_data |> to_string
562 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
563 URI.merge(page_url_data, image_url_data) |> to_string
566 defp build_image_url(_, _), do: nil