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 defp reblogged?(activity, user) do
69 object = Object.normalize(activity, fetch: false) || %{}
70 present?(user && user.ap_id in (object.data["announcements"] || []))
73 def render("index.json", opts) do
74 reading_user = opts[:for]
76 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
77 activities = Enum.filter(opts.activities, & &1)
79 # Start fetching rich media before doing anything else, so that later calls to get the cards
80 # only block for timeout in the worst case, as opposed to
81 # length(activities_with_links) * timeout
82 fetch_rich_media_for_activities(activities)
83 replied_to_activities = get_replied_to_activities(activities)
87 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
88 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
89 |> Activity.create_by_object_ap_id()
90 |> Activity.with_preloaded_object(:left)
91 |> Activity.with_preloaded_bookmark(reading_user)
92 |> Activity.with_set_thread_muted_field(reading_user)
97 Map.has_key?(opts, :relationships) ->
100 is_nil(reading_user) ->
101 UserRelationship.view_relationships_option(nil, [])
104 # Note: unresolved users are filtered out
106 (activities ++ parent_activities)
107 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
110 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
115 |> Map.put(:replied_to_activities, replied_to_activities)
116 |> Map.put(:parent_activities, parent_activities)
117 |> Map.put(:relationships, relationships_opt)
119 safe_render_many(activities, StatusView, "show.json", opts)
124 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
126 user = CommonAPI.get_user(activity.data["actor"])
127 created_at = Utils.to_masto_date(activity.data["published"])
128 activity_object = Object.normalize(activity, fetch: false)
130 reblogged_parent_activity =
131 if opts[:parent_activities] do
132 Activity.Queries.find_by_object_ap_id(
133 opts[:parent_activities],
134 activity_object.data["id"]
137 Activity.create_by_object_ap_id(activity_object.data["id"])
138 |> Activity.with_preloaded_bookmark(opts[:for])
139 |> Activity.with_set_thread_muted_field(opts[:for])
143 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
144 reblogged = render("show.json", reblog_rendering_opts)
146 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
148 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
152 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
154 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
157 id: to_string(activity.id),
158 uri: activity_object.data["id"],
159 url: activity_object.data["id"],
161 AccountView.render("show.json", %{
166 in_reply_to_account_id: nil,
168 content: reblogged[:content] || "",
169 created_at: created_at,
173 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
174 favourited: present?(favorited),
175 bookmarked: present?(bookmarked),
177 pinned: pinned?(activity, user),
180 visibility: get_visibility(activity),
181 media_attachments: reblogged[:media_attachments] || [],
183 tags: reblogged[:tags] || [],
184 application: build_application(activity_object.data["generator"]),
188 local: activity.local
193 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
194 object = Object.normalize(activity, fetch: false)
196 user = CommonAPI.get_user(activity.data["actor"])
197 user_follower_address = user.follower_address
199 like_count = object.data["like_count"] || 0
200 announcement_count = object.data["announcement_count"] || 0
202 tags = object.data["tag"] || []
203 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
207 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
208 |> Enum.map(fn tag -> tag["href"] end)
211 (object.data["to"] ++ tag_mentions)
214 Pleroma.Constants.as_public() -> nil
215 ^user_follower_address -> nil
216 ap_id -> User.get_cached_by_ap_id(ap_id)
219 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
221 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
223 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
225 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
228 with true <- client_posted_this_activity,
229 %Oban.Job{scheduled_at: scheduled_at} <-
230 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
238 is_nil(opts[:for]) -> false
239 is_boolean(activity.thread_muted?) -> activity.thread_muted?
240 true -> CommonAPI.thread_muted?(opts[:for], activity)
243 attachment_data = object.data["attachment"] || []
244 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
246 created_at = Utils.to_masto_date(object.data["published"])
248 reply_to = get_reply_to(activity, opts)
250 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
258 |> HTML.get_cached_scrubbed_html_for_activity(
259 User.html_filter_policy(opts[:for]),
266 |> HTML.get_cached_stripped_html_for_activity(
271 summary = object.data["summary"] || ""
273 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
277 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
279 object.data["url"] || object.data["external_url"] || object.data["id"]
282 direct_conversation_id =
283 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
284 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
285 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
286 Activity.direct_conversation_id(activity, for_user)
288 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
297 |> Map.get("reactions", [])
298 |> EmojiReactionController.filter_allowed_users(
300 Map.get(opts, :with_muted, false)
302 |> Stream.map(fn {emoji, users} ->
303 build_emoji_map(emoji, users, opts[:for])
307 # Status muted state (would do 1 request per status unless user mutes are preloaded)
310 UserRelationship.exists?(
311 get_in(opts, [:relationships, :user_relationships]),
315 fn for_user, user -> User.mutes?(for_user, user) end
319 id: to_string(activity.id),
320 uri: object.data["id"],
323 AccountView.render("show.json", %{
327 in_reply_to_id: reply_to && to_string(reply_to.id),
328 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
331 content: content_html,
332 text: opts[:with_source] && object.data["source"],
333 created_at: created_at,
334 reblogs_count: announcement_count,
335 replies_count: object.data["repliesCount"] || 0,
336 favourites_count: like_count,
337 reblogged: reblogged?(activity, opts[:for]),
338 favourited: present?(favorited),
339 bookmarked: present?(bookmarked),
341 pinned: pinned?(activity, user),
342 sensitive: sensitive,
343 spoiler_text: summary,
344 visibility: get_visibility(object),
345 media_attachments: attachments,
346 poll: render(PollView, "show.json", object: object, for: opts[:for]),
348 tags: build_tags(tags),
349 application: build_application(object.data["generator"]),
351 emojis: build_emojis(object.data["emoji"]),
353 local: activity.local,
354 conversation_id: get_context_id(activity),
355 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
356 content: %{"text/plain" => content_plaintext},
357 spoiler_text: %{"text/plain" => summary},
358 expires_at: expires_at,
359 direct_conversation_id: direct_conversation_id,
360 thread_muted: thread_muted?,
361 emoji_reactions: emoji_reactions,
362 parent_visible: visible_for_user?(reply_to, opts[:for])
367 def render("show.json", _) do
371 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
372 page_url_data = URI.parse(page_url)
375 if is_binary(rich_media["url"]) do
376 URI.merge(page_url_data, URI.parse(rich_media["url"]))
381 page_url = page_url_data |> to_string
384 if is_binary(rich_media["image"]) do
385 URI.merge(page_url_data, URI.parse(rich_media["image"]))
391 provider_name: page_url_data.host,
392 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
394 image: image_url |> MediaProxy.url(),
395 title: rich_media["title"] || "",
396 description: rich_media["description"] || "",
398 opengraph: rich_media
403 def render("card.json", _), do: nil
405 def render("attachment.json", %{attachment: attachment}) do
406 [attachment_url | _] = attachment["url"]
407 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
408 href = attachment_url["href"] |> MediaProxy.url()
409 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
410 meta = render("attachment_meta.json", %{attachment: attachment})
414 String.contains?(media_type, "image") -> "image"
415 String.contains?(media_type, "video") -> "video"
416 String.contains?(media_type, "audio") -> "audio"
420 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
423 id: to_string(attachment["id"] || hash_id),
426 preview_url: href_preview,
429 description: attachment["name"],
430 pleroma: %{mime_type: media_type},
431 blurhash: attachment["blurhash"]
433 |> Maps.put_if_present(:meta, meta)
436 def render("attachment_meta.json", %{
437 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
439 when is_integer(width) and is_integer(height) do
444 aspect: width / height
449 def render("attachment_meta.json", _), do: nil
451 def render("context.json", %{activity: activity, activities: activities, user: user}) do
452 %{ancestors: ancestors, descendants: descendants} =
455 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
456 |> Map.put_new(:ancestors, [])
457 |> Map.put_new(:descendants, [])
460 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
461 descendants: render("index.json", for: user, activities: descendants, as: :activity)
465 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
466 object = Object.normalize(activity, fetch: false)
468 with nil <- replied_to_activities[object.data["inReplyTo"]] do
469 # If user didn't participate in the thread
470 Activity.get_in_reply_to_activity(activity)
474 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
475 object = Object.normalize(activity, fetch: false)
477 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
478 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
484 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
485 url = object.data["url"] || object.data["id"]
487 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
490 def render_content(object), do: object.data["content"] || ""
493 Builds a dictionary tags.
497 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
498 [{"name": "fediverse", "url": "/tag/fediverse"},
499 {"name": "nextcloud", "url": "/tag/nextcloud"}]
502 @spec build_tags(list(any())) :: list(map())
503 def build_tags(object_tags) when is_list(object_tags) do
505 |> Enum.filter(&is_binary/1)
506 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"})
509 def build_tags(_), do: []
514 Arguments: `nil` or list tuple of name and url.
520 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
521 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
524 @spec build_emojis(nil | list(tuple())) :: list(map())
525 def build_emojis(nil), do: []
527 def build_emojis(emojis) do
529 |> Enum.map(fn {name, url} ->
530 name = HTML.strip_tags(name)
537 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
541 defp present?(nil), do: false
542 defp present?(false), do: false
543 defp present?(_), do: true
545 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
546 do: id in pinned_activities
548 defp build_emoji_map(emoji, users, current_user) do
551 count: length(users),
552 me: !!(current_user && current_user.ap_id in users)
556 @spec build_application(map() | nil) :: map() | nil
557 defp build_application(%{type: _type, name: name, url: url}), do: %{name: name, website: url}
558 defp build_application(_), do: nil