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] || [],
183 application: build_application(activity_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 tags = object.data["tag"] || []
202 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
206 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
207 |> Enum.map(fn tag -> tag["href"] end)
210 (object.data["to"] ++ tag_mentions)
213 Pleroma.Constants.as_public() -> nil
214 ^user_follower_address -> nil
215 ap_id -> User.get_cached_by_ap_id(ap_id)
218 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
220 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
222 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
224 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
227 with true <- client_posted_this_activity,
228 %Oban.Job{scheduled_at: scheduled_at} <-
229 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
237 is_nil(opts[:for]) -> false
238 is_boolean(activity.thread_muted?) -> activity.thread_muted?
239 true -> CommonAPI.thread_muted?(opts[:for], activity)
242 attachment_data = object.data["attachment"] || []
243 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
245 created_at = Utils.to_masto_date(object.data["published"])
247 reply_to = get_reply_to(activity, opts)
249 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
257 |> HTML.get_cached_scrubbed_html_for_activity(
258 User.html_filter_policy(opts[:for]),
265 |> HTML.get_cached_stripped_html_for_activity(
270 summary = object.data["summary"] || ""
272 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
276 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
278 object.data["url"] || object.data["external_url"] || object.data["id"]
281 direct_conversation_id =
282 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
283 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
284 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
285 Activity.direct_conversation_id(activity, for_user)
287 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
296 |> Map.get("reactions", [])
297 |> EmojiReactionController.filter_allowed_users(
299 Map.get(opts, :with_muted, false)
301 |> Stream.map(fn {emoji, users} ->
302 build_emoji_map(emoji, users, opts[:for])
306 # Status muted state (would do 1 request per status unless user mutes are preloaded)
309 UserRelationship.exists?(
310 get_in(opts, [:relationships, :user_relationships]),
314 fn for_user, user -> User.mutes?(for_user, user) end
318 id: to_string(activity.id),
319 uri: object.data["id"],
322 AccountView.render("show.json", %{
326 in_reply_to_id: reply_to && to_string(reply_to.id),
327 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
330 content: content_html,
331 text: opts[:with_source] && object.data["source"],
332 created_at: created_at,
333 reblogs_count: announcement_count,
334 replies_count: object.data["repliesCount"] || 0,
335 favourites_count: like_count,
336 reblogged: reblogged?(activity, opts[:for]),
337 favourited: present?(favorited),
338 bookmarked: present?(bookmarked),
340 pinned: pinned?(activity, user),
341 sensitive: sensitive,
342 spoiler_text: summary,
343 visibility: get_visibility(object),
344 media_attachments: attachments,
345 poll: render(PollView, "show.json", object: object, for: opts[:for]),
347 tags: build_tags(tags),
348 application: build_application(object.data["generator"]),
350 emojis: build_emojis(object.data["emoji"]),
352 local: activity.local,
353 conversation_id: get_context_id(activity),
354 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
355 content: %{"text/plain" => content_plaintext},
356 spoiler_text: %{"text/plain" => summary},
357 expires_at: expires_at,
358 direct_conversation_id: direct_conversation_id,
359 thread_muted: thread_muted?,
360 emoji_reactions: emoji_reactions,
361 parent_visible: visible_for_user?(reply_to, opts[:for])
366 def render("show.json", _) do
370 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
371 page_url_data = URI.parse(page_url)
374 if is_binary(rich_media["url"]) do
375 URI.merge(page_url_data, URI.parse(rich_media["url"]))
380 page_url = page_url_data |> to_string
383 if is_binary(rich_media["image"]) do
384 URI.merge(page_url_data, URI.parse(rich_media["image"]))
390 provider_name: page_url_data.host,
391 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
393 image: image_url |> MediaProxy.url(),
394 title: rich_media["title"] || "",
395 description: rich_media["description"] || "",
397 opengraph: rich_media
402 def render("card.json", _), do: nil
404 def render("attachment.json", %{attachment: attachment}) do
405 [attachment_url | _] = attachment["url"]
406 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
407 href = attachment_url["href"] |> MediaProxy.url()
408 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
412 String.contains?(media_type, "image") -> "image"
413 String.contains?(media_type, "video") -> "video"
414 String.contains?(media_type, "audio") -> "audio"
418 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
421 id: to_string(attachment["id"] || hash_id),
424 preview_url: href_preview,
427 description: attachment["name"],
428 pleroma: %{mime_type: media_type},
429 blurhash: attachment["blurhash"]
433 def render("context.json", %{activity: activity, activities: activities, user: user}) do
434 %{ancestors: ancestors, descendants: descendants} =
437 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
438 |> Map.put_new(:ancestors, [])
439 |> Map.put_new(:descendants, [])
442 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
443 descendants: render("index.json", for: user, activities: descendants, as: :activity)
447 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
448 object = Object.normalize(activity, fetch: false)
450 with nil <- replied_to_activities[object.data["inReplyTo"]] do
451 # If user didn't participate in the thread
452 Activity.get_in_reply_to_activity(activity)
456 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
457 object = Object.normalize(activity, fetch: false)
459 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
460 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
466 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
467 url = object.data["url"] || object.data["id"]
469 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
472 def render_content(object), do: object.data["content"] || ""
475 Builds a dictionary tags.
479 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
480 [{"name": "fediverse", "url": "/tag/fediverse"},
481 {"name": "nextcloud", "url": "/tag/nextcloud"}]
484 @spec build_tags(list(any())) :: list(map())
485 def build_tags(object_tags) when is_list(object_tags) do
487 |> Enum.filter(&is_binary/1)
488 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"})
491 def build_tags(_), do: []
496 Arguments: `nil` or list tuple of name and url.
502 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
503 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
506 @spec build_emojis(nil | list(tuple())) :: list(map())
507 def build_emojis(nil), do: []
509 def build_emojis(emojis) do
511 |> Enum.map(fn {name, url} ->
512 name = HTML.strip_tags(name)
519 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
523 defp present?(nil), do: false
524 defp present?(false), do: false
525 defp present?(_), do: true
527 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
528 do: id in pinned_activities
530 defp build_emoji_map(emoji, users, current_user) do
533 count: length(users),
534 me: !!(current_user && current_user.ap_id in users)
538 @spec build_application(map() | nil) :: map() | nil
539 defp build_application(%{type: _type, name: name, url: url}), do: %{name: name, website: url}
540 defp build_application(_), do: nil