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
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)
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 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) || %{}
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).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)
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)
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 hashtags = Object.hashtags(object)
205 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
207 tags = Object.tags(object)
211 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
212 |> Enum.map(fn tag -> tag["href"] end)
215 (object.data["to"] ++ tag_mentions)
218 Pleroma.Constants.as_public() -> nil
219 ^user_follower_address -> nil
220 ap_id -> User.get_cached_by_ap_id(ap_id)
223 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
225 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
227 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
229 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
232 with true <- client_posted_this_activity,
233 %Oban.Job{scheduled_at: scheduled_at} <-
234 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
242 is_nil(opts[:for]) -> false
243 is_boolean(activity.thread_muted?) -> activity.thread_muted?
244 true -> CommonAPI.thread_muted?(opts[:for], activity)
247 attachment_data = object.data["attachment"] || []
248 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
250 created_at = Utils.to_masto_date(object.data["published"])
252 reply_to = get_reply_to(activity, opts)
254 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
262 |> HTML.get_cached_scrubbed_html_for_activity(
263 User.html_filter_policy(opts[:for]),
270 |> HTML.get_cached_stripped_html_for_activity(
275 summary = object.data["summary"] || ""
277 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
281 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
283 object.data["url"] || object.data["external_url"] || object.data["id"]
286 direct_conversation_id =
287 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
288 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
289 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
290 Activity.direct_conversation_id(activity, for_user)
292 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
301 |> Map.get("reactions", [])
302 |> EmojiReactionController.filter_allowed_users(
304 Map.get(opts, :with_muted, false)
306 |> Stream.map(fn {emoji, users} ->
307 build_emoji_map(emoji, users, opts[:for])
311 # Status muted state (would do 1 request per status unless user mutes are preloaded)
314 UserRelationship.exists?(
315 get_in(opts, [:relationships, :user_relationships]),
319 fn for_user, user -> User.mutes?(for_user, user) end
323 id: to_string(activity.id),
324 uri: object.data["id"],
327 AccountView.render("show.json", %{
331 in_reply_to_id: reply_to && to_string(reply_to.id),
332 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
335 content: content_html,
336 text: opts[:with_source] && object.data["source"],
337 created_at: created_at,
338 reblogs_count: announcement_count,
339 replies_count: object.data["repliesCount"] || 0,
340 favourites_count: like_count,
341 reblogged: reblogged?(activity, opts[:for]),
342 favourited: present?(favorited),
343 bookmarked: present?(bookmarked),
345 pinned: pinned?(activity, user),
346 sensitive: sensitive,
347 spoiler_text: summary,
348 visibility: get_visibility(object),
349 media_attachments: attachments,
350 poll: render(PollView, "show.json", object: object, for: opts[:for]),
352 tags: build_tags(tags),
358 emojis: build_emojis(object.data["emoji"]),
360 local: activity.local,
361 conversation_id: get_context_id(activity),
362 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
363 content: %{"text/plain" => content_plaintext},
364 spoiler_text: %{"text/plain" => summary},
365 expires_at: expires_at,
366 direct_conversation_id: direct_conversation_id,
367 thread_muted: thread_muted?,
368 emoji_reactions: emoji_reactions,
369 parent_visible: visible_for_user?(reply_to, opts[:for])
374 def render("show.json", _) do
378 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
379 page_url_data = URI.parse(page_url)
382 if is_binary(rich_media["url"]) do
383 URI.merge(page_url_data, URI.parse(rich_media["url"]))
388 page_url = page_url_data |> to_string
391 if is_binary(rich_media["image"]) do
392 URI.merge(page_url_data, URI.parse(rich_media["image"]))
398 provider_name: page_url_data.host,
399 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
401 image: image_url |> MediaProxy.url(),
402 title: rich_media["title"] || "",
403 description: rich_media["description"] || "",
405 opengraph: rich_media
410 def render("card.json", _), do: nil
412 def render("attachment.json", %{attachment: attachment}) do
413 [attachment_url | _] = attachment["url"]
414 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
415 href = attachment_url["href"] |> MediaProxy.url()
416 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
420 String.contains?(media_type, "image") -> "image"
421 String.contains?(media_type, "video") -> "video"
422 String.contains?(media_type, "audio") -> "audio"
426 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
429 id: to_string(attachment["id"] || hash_id),
432 preview_url: href_preview,
435 description: attachment["name"],
436 pleroma: %{mime_type: media_type},
437 blurhash: attachment["blurhash"]
441 def render("context.json", %{activity: activity, activities: activities, user: user}) do
442 %{ancestors: ancestors, descendants: descendants} =
445 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
446 |> Map.put_new(:ancestors, [])
447 |> Map.put_new(:descendants, [])
450 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
451 descendants: render("index.json", for: user, activities: descendants, as: :activity)
455 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
456 object = Object.normalize(activity)
458 with nil <- replied_to_activities[object.data["inReplyTo"]] do
459 # If user didn't participate in the thread
460 Activity.get_in_reply_to_activity(activity)
464 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
465 object = Object.normalize(activity)
467 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
468 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
474 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
475 url = object.data["url"] || object.data["id"]
477 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
480 def render_content(object), do: object.data["content"] || ""
483 Builds a dictionary tags.
487 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
488 [{"name": "fediverse", "url": "/tag/fediverse"},
489 {"name": "nextcloud", "url": "/tag/nextcloud"}]
492 @spec build_tags(list(any())) :: list(map())
493 def build_tags(object_tags) when is_list(object_tags) do
495 |> Enum.filter(&is_binary/1)
496 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
499 def build_tags(_), do: []
504 Arguments: `nil` or list tuple of name and url.
510 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
511 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
514 @spec build_emojis(nil | list(tuple())) :: list(map())
515 def build_emojis(nil), do: []
517 def build_emojis(emojis) do
519 |> Enum.map(fn {name, url} ->
520 name = HTML.strip_tags(name)
527 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
531 defp present?(nil), do: false
532 defp present?(false), do: false
533 defp present?(_), do: true
535 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
536 do: id in pinned_activities
538 defp build_emoji_map(emoji, users, current_user) do
541 count: length(users),
542 me: !!(current_user && current_user.ap_id in users)