1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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
11 alias Pleroma.ActivityExpiration
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
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)
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)
55 if object, do: Map.put(acc, object.data["id"], activity), else: acc
59 def get_user(ap_id, fake_record_fallback \\ true) do
61 user = User.get_cached_by_ap_id(ap_id) ->
64 user = User.get_by_guessed_nickname(ap_id) ->
67 fake_record_fallback ->
68 # TODO: refactor (fake records is never a good idea)
69 User.error_user(ap_id)
76 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
79 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
80 do: Utils.context_to_conversation_id(context)
82 defp get_context_id(_), do: nil
84 defp reblogged?(activity, user) do
85 object = Object.normalize(activity) || %{}
86 present?(user && user.ap_id in (object.data["announcements"] || []))
89 def render("index.json", opts) do
90 reading_user = opts[:for]
92 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
93 activities = Enum.filter(opts.activities, & &1)
95 # Start fetching rich media before doing anything else, so that later calls to get the cards
96 # only block for timeout in the worst case, as opposed to
97 # length(activities_with_links) * timeout
98 fetch_rich_media_for_activities(activities)
99 replied_to_activities = get_replied_to_activities(activities)
103 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
104 |> Enum.map(&Object.normalize(&1).data["id"])
105 |> Activity.create_by_object_ap_id()
106 |> Activity.with_preloaded_object(:left)
107 |> Activity.with_preloaded_bookmark(reading_user)
108 |> Activity.with_set_thread_muted_field(reading_user)
113 Map.has_key?(opts, :relationships) ->
116 is_nil(reading_user) ->
117 UserRelationship.view_relationships_option(nil, [])
120 # Note: unresolved users are filtered out
122 (activities ++ parent_activities)
123 |> Enum.map(&get_user(&1.data["actor"], false))
126 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
131 |> Map.put(:replied_to_activities, replied_to_activities)
132 |> Map.put(:parent_activities, parent_activities)
133 |> Map.put(:relationships, relationships_opt)
135 safe_render_many(activities, StatusView, "show.json", opts)
140 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
142 user = get_user(activity.data["actor"])
143 created_at = Utils.to_masto_date(activity.data["published"])
144 activity_object = Object.normalize(activity)
146 reblogged_parent_activity =
147 if opts[:parent_activities] do
148 Activity.Queries.find_by_object_ap_id(
149 opts[:parent_activities],
150 activity_object.data["id"]
153 Activity.create_by_object_ap_id(activity_object.data["id"])
154 |> Activity.with_preloaded_bookmark(opts[:for])
155 |> Activity.with_set_thread_muted_field(opts[:for])
159 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
160 reblogged = render("show.json", reblog_rendering_opts)
162 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
164 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
168 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
170 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
173 id: to_string(activity.id),
174 uri: activity_object.data["id"],
175 url: activity_object.data["id"],
177 AccountView.render("show.json", %{
182 in_reply_to_account_id: nil,
184 content: reblogged[:content] || "",
185 created_at: created_at,
189 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
190 favourited: present?(favorited),
191 bookmarked: present?(bookmarked),
193 pinned: pinned?(activity, user),
196 visibility: get_visibility(activity),
197 media_attachments: reblogged[:media_attachments] || [],
199 tags: reblogged[:tags] || [],
207 local: activity.local
212 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
213 object = Object.normalize(activity)
215 user = get_user(activity.data["actor"])
216 user_follower_address = user.follower_address
218 like_count = object.data["like_count"] || 0
219 announcement_count = object.data["announcement_count"] || 0
221 tags = object.data["tag"] || []
222 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
226 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
227 |> Enum.map(fn tag -> tag["href"] end)
230 (object.data["to"] ++ tag_mentions)
233 Pleroma.Constants.as_public() -> nil
234 ^user_follower_address -> nil
235 ap_id -> User.get_cached_by_ap_id(ap_id)
238 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
240 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
242 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
244 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
247 with true <- client_posted_this_activity,
248 %ActivityExpiration{scheduled_at: scheduled_at} <-
249 ActivityExpiration.get_by_activity_id(activity.id) do
257 is_nil(opts[:for]) -> false
258 is_boolean(activity.thread_muted?) -> activity.thread_muted?
259 true -> CommonAPI.thread_muted?(opts[:for], activity)
262 attachment_data = object.data["attachment"] || []
263 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
265 created_at = Utils.to_masto_date(object.data["published"])
267 reply_to = get_reply_to(activity, opts)
269 reply_to_user = reply_to && get_user(reply_to.data["actor"])
277 |> HTML.get_cached_scrubbed_html_for_activity(
278 User.html_filter_policy(opts[:for]),
285 |> HTML.get_cached_stripped_html_for_activity(
290 summary = object.data["summary"] || ""
292 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
296 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
298 object.data["url"] || object.data["external_url"] || object.data["id"]
301 direct_conversation_id =
302 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
303 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
304 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
305 Activity.direct_conversation_id(activity, for_user)
307 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
315 with %{data: %{"reactions" => emoji_reactions}} <- object do
316 Enum.map(emoji_reactions, fn
317 [emoji, users] when is_list(users) ->
318 build_emoji_map(emoji, users, opts[:for])
320 {emoji, users} when is_list(users) ->
321 build_emoji_map(emoji, users, opts[:for])
326 |> Enum.reject(&is_nil/1)
331 # Status muted state (would do 1 request per status unless user mutes are preloaded)
334 UserRelationship.exists?(
335 get_in(opts, [:relationships, :user_relationships]),
339 fn for_user, user -> User.mutes?(for_user, user) end
343 id: to_string(activity.id),
344 uri: object.data["id"],
347 AccountView.render("show.json", %{
351 in_reply_to_id: reply_to && to_string(reply_to.id),
352 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
355 content: content_html,
356 text: opts[:with_source] && object.data["source"],
357 created_at: created_at,
358 reblogs_count: announcement_count,
359 replies_count: object.data["repliesCount"] || 0,
360 favourites_count: like_count,
361 reblogged: reblogged?(activity, opts[:for]),
362 favourited: present?(favorited),
363 bookmarked: present?(bookmarked),
365 pinned: pinned?(activity, user),
366 sensitive: sensitive,
367 spoiler_text: summary,
368 visibility: get_visibility(object),
369 media_attachments: attachments,
370 poll: render(PollView, "show.json", object: object, for: opts[:for]),
372 tags: build_tags(tags),
378 emojis: build_emojis(object.data["emoji"]),
380 local: activity.local,
381 conversation_id: get_context_id(activity),
382 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
383 content: %{"text/plain" => content_plaintext},
384 spoiler_text: %{"text/plain" => summary},
385 expires_at: expires_at,
386 direct_conversation_id: direct_conversation_id,
387 thread_muted: thread_muted?,
388 emoji_reactions: emoji_reactions,
389 parent_visible: visible_for_user?(reply_to, opts[:for])
394 def render("show.json", _) do
398 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
399 page_url_data = URI.parse(page_url)
402 if is_binary(rich_media["url"]) do
403 URI.merge(page_url_data, URI.parse(rich_media["url"]))
408 page_url = page_url_data |> to_string
411 if is_binary(rich_media["image"]) do
412 URI.merge(page_url_data, URI.parse(rich_media["image"]))
418 provider_name: page_url_data.host,
419 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
421 image: image_url |> MediaProxy.url(),
422 title: rich_media["title"] || "",
423 description: rich_media["description"] || "",
425 opengraph: rich_media
430 def render("card.json", _), do: nil
432 def render("attachment.json", %{attachment: attachment}) do
433 [attachment_url | _] = attachment["url"]
434 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
435 href = attachment_url["href"] |> MediaProxy.url()
436 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
440 String.contains?(media_type, "image") -> "image"
441 String.contains?(media_type, "video") -> "video"
442 String.contains?(media_type, "audio") -> "audio"
446 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
449 id: to_string(attachment["id"] || hash_id),
452 preview_url: href_preview,
455 description: attachment["name"],
456 pleroma: %{mime_type: media_type}
460 def render("context.json", %{activity: activity, activities: activities, user: user}) do
461 %{ancestors: ancestors, descendants: descendants} =
464 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
465 |> Map.put_new(:ancestors, [])
466 |> Map.put_new(:descendants, [])
469 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
470 descendants: render("index.json", for: user, activities: descendants, as: :activity)
474 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
475 object = Object.normalize(activity)
477 with nil <- replied_to_activities[object.data["inReplyTo"]] do
478 # If user didn't participate in the thread
479 Activity.get_in_reply_to_activity(activity)
483 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
484 object = Object.normalize(activity)
486 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
487 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
493 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
494 url = object.data["url"] || object.data["id"]
496 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
499 def render_content(object), do: object.data["content"] || ""
502 Builds a dictionary tags.
506 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
507 [{"name": "fediverse", "url": "/tag/fediverse"},
508 {"name": "nextcloud", "url": "/tag/nextcloud"}]
511 @spec build_tags(list(any())) :: list(map())
512 def build_tags(object_tags) when is_list(object_tags) do
514 |> Enum.filter(&is_binary/1)
515 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
518 def build_tags(_), do: []
523 Arguments: `nil` or list tuple of name and url.
529 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
530 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
533 @spec build_emojis(nil | list(tuple())) :: list(map())
534 def build_emojis(nil), do: []
536 def build_emojis(emojis) do
538 |> Enum.map(fn {name, url} ->
539 name = HTML.strip_tags(name)
546 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
550 defp present?(nil), do: false
551 defp present?(false), do: false
552 defp present?(_), do: true
554 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
555 do: id in pinned_activities
557 defp build_emoji_map(emoji, users, current_user) do
560 count: length(users),
561 me: !!(current_user && current_user.ap_id in users)