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 activity_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],
133 activity_object.data["id"]
136 Activity.create_by_object_ap_id(activity_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 (activity_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: activity_object.data["id"],
158 url: activity_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] || [],
190 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 tags = object.data["tag"] || []
205 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
209 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
210 |> Enum.map(fn tag -> tag["href"] end)
213 (object.data["to"] ++ tag_mentions)
216 Pleroma.Constants.as_public() -> nil
217 ^user_follower_address -> nil
218 ap_id -> User.get_cached_by_ap_id(ap_id)
221 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
223 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
225 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
227 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
230 with true <- client_posted_this_activity,
231 %Oban.Job{scheduled_at: scheduled_at} <-
232 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
240 is_nil(opts[:for]) -> false
241 is_boolean(activity.thread_muted?) -> activity.thread_muted?
242 true -> CommonAPI.thread_muted?(opts[:for], activity)
245 attachment_data = object.data["attachment"] || []
246 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
248 created_at = Utils.to_masto_date(object.data["published"])
250 reply_to = get_reply_to(activity, opts)
252 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
260 |> HTML.get_cached_scrubbed_html_for_activity(
261 User.html_filter_policy(opts[:for]),
268 |> HTML.get_cached_stripped_html_for_activity(
273 summary = object.data["summary"] || ""
275 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
279 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
281 object.data["url"] || object.data["external_url"] || object.data["id"]
284 direct_conversation_id =
285 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
286 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
287 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
288 Activity.direct_conversation_id(activity, for_user)
290 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
299 |> Map.get("reactions", [])
300 |> EmojiReactionController.filter_allowed_users(
302 Map.get(opts, :with_muted, false)
304 |> Stream.map(fn {emoji, users} ->
305 build_emoji_map(emoji, users, opts[:for])
309 # Status muted state (would do 1 request per status unless user mutes are preloaded)
312 UserRelationship.exists?(
313 get_in(opts, [:relationships, :user_relationships]),
317 fn for_user, user -> User.mutes?(for_user, user) end
321 id: to_string(activity.id),
322 uri: object.data["id"],
325 AccountView.render("show.json", %{
329 in_reply_to_id: reply_to && to_string(reply_to.id),
330 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
333 content: content_html,
334 text: opts[:with_source] && object.data["source"],
335 created_at: created_at,
336 reblogs_count: announcement_count,
337 replies_count: object.data["repliesCount"] || 0,
338 favourites_count: like_count,
339 reblogged: reblogged?(activity, opts[:for]),
340 favourited: present?(favorited),
341 bookmarked: present?(bookmarked),
343 pinned: pinned?(activity, user),
344 sensitive: sensitive,
345 spoiler_text: summary,
346 visibility: get_visibility(object),
347 media_attachments: attachments,
348 poll: render(PollView, "show.json", object: object, for: opts[:for]),
350 tags: build_tags(tags),
356 emojis: build_emojis(object.data["emoji"]),
358 local: activity.local,
359 conversation_id: get_context_id(activity),
360 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
361 content: %{"text/plain" => content_plaintext},
362 spoiler_text: %{"text/plain" => summary},
363 expires_at: expires_at,
364 direct_conversation_id: direct_conversation_id,
365 thread_muted: thread_muted?,
366 emoji_reactions: emoji_reactions,
367 parent_visible: visible_for_user?(reply_to, opts[:for])
372 def render("show.json", _) do
376 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
377 page_url_data = URI.parse(page_url)
380 if is_binary(rich_media["url"]) do
381 URI.merge(page_url_data, URI.parse(rich_media["url"]))
386 page_url = page_url_data |> to_string
389 if is_binary(rich_media["image"]) do
390 URI.merge(page_url_data, URI.parse(rich_media["image"]))
396 provider_name: page_url_data.host,
397 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
399 image: image_url |> MediaProxy.url(),
400 title: rich_media["title"] || "",
401 description: rich_media["description"] || "",
403 opengraph: rich_media
408 def render("card.json", _), do: nil
410 def render("attachment.json", %{attachment: attachment}) do
411 [attachment_url | _] = attachment["url"]
412 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
413 href = attachment_url["href"] |> MediaProxy.url()
414 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
418 String.contains?(media_type, "image") -> "image"
419 String.contains?(media_type, "video") -> "video"
420 String.contains?(media_type, "audio") -> "audio"
424 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
427 id: to_string(attachment["id"] || hash_id),
430 preview_url: href_preview,
433 description: attachment["name"],
434 pleroma: %{mime_type: media_type},
435 blurhash: attachment["blurhash"]
439 def render("context.json", %{activity: activity, activities: activities, user: user}) do
440 %{ancestors: ancestors, descendants: descendants} =
443 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
444 |> Map.put_new(:ancestors, [])
445 |> Map.put_new(:descendants, [])
448 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
449 descendants: render("index.json", for: user, activities: descendants, as: :activity)
453 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
454 object = Object.normalize(activity, fetch: false)
456 with nil <- replied_to_activities[object.data["inReplyTo"]] do
457 # If user didn't participate in the thread
458 Activity.get_in_reply_to_activity(activity)
462 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
463 object = Object.normalize(activity, fetch: false)
465 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
466 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
472 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
473 url = object.data["url"] || object.data["id"]
475 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
478 def render_content(object), do: object.data["content"] || ""
481 Builds a dictionary tags.
485 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
486 [{"name": "fediverse", "url": "/tag/fediverse"},
487 {"name": "nextcloud", "url": "/tag/nextcloud"}]
490 @spec build_tags(list(any())) :: list(map())
491 def build_tags(object_tags) when is_list(object_tags) do
493 |> Enum.filter(&is_binary/1)
494 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"})
497 def build_tags(_), do: []
502 Arguments: `nil` or list tuple of name and url.
508 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
509 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
512 @spec build_emojis(nil | list(tuple())) :: list(map())
513 def build_emojis(nil), do: []
515 def build_emojis(emojis) do
517 |> Enum.map(fn {name, url} ->
518 name = HTML.strip_tags(name)
525 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
529 defp present?(nil), do: false
530 defp present?(false), do: false
531 defp present?(_), do: true
533 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
534 do: id in pinned_activities
536 defp build_emoji_map(emoji, users, current_user) do
539 count: length(users),
540 me: !!(current_user && current_user.ap_id in users)