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 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 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 %ActivityExpiration{scheduled_at: scheduled_at} <-
232 ActivityExpiration.get_by_activity_id(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) ->
298 with %{data: %{"reactions" => emoji_reactions}} <- object do
299 Enum.map(emoji_reactions, fn
300 [emoji, users] when is_list(users) ->
301 build_emoji_map(emoji, users, opts[:for])
303 {emoji, users} when is_list(users) ->
304 build_emoji_map(emoji, users, opts[:for])
309 |> Enum.reject(&is_nil/1)
314 # Status muted state (would do 1 request per status unless user mutes are preloaded)
317 UserRelationship.exists?(
318 get_in(opts, [:relationships, :user_relationships]),
322 fn for_user, user -> User.mutes?(for_user, user) end
326 id: to_string(activity.id),
327 uri: object.data["id"],
330 AccountView.render("show.json", %{
334 in_reply_to_id: reply_to && to_string(reply_to.id),
335 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
338 content: content_html,
339 text: opts[:with_source] && object.data["source"],
340 created_at: created_at,
341 reblogs_count: announcement_count,
342 replies_count: object.data["repliesCount"] || 0,
343 favourites_count: like_count,
344 reblogged: reblogged?(activity, opts[:for]),
345 favourited: present?(favorited),
346 bookmarked: present?(bookmarked),
348 pinned: pinned?(activity, user),
349 sensitive: sensitive,
350 spoiler_text: summary,
351 visibility: get_visibility(object),
352 media_attachments: attachments,
353 poll: render(PollView, "show.json", object: object, for: opts[:for]),
355 tags: build_tags(tags),
361 emojis: build_emojis(object.data["emoji"]),
363 local: activity.local,
364 conversation_id: get_context_id(activity),
365 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
366 content: %{"text/plain" => content_plaintext},
367 spoiler_text: %{"text/plain" => summary},
368 expires_at: expires_at,
369 direct_conversation_id: direct_conversation_id,
370 thread_muted: thread_muted?,
371 emoji_reactions: emoji_reactions,
372 parent_visible: visible_for_user?(reply_to, opts[:for])
377 def render("show.json", _) do
381 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
382 page_url_data = URI.parse(page_url)
385 if is_binary(rich_media["url"]) do
386 URI.merge(page_url_data, URI.parse(rich_media["url"]))
391 page_url = page_url_data |> to_string
394 if is_binary(rich_media["image"]) do
395 URI.merge(page_url_data, URI.parse(rich_media["image"]))
401 provider_name: page_url_data.host,
402 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
404 image: image_url |> MediaProxy.url(),
405 title: rich_media["title"] || "",
406 description: rich_media["description"] || "",
408 opengraph: rich_media
413 def render("card.json", _), do: nil
415 def render("attachment.json", %{attachment: attachment}) do
416 [attachment_url | _] = attachment["url"]
417 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
418 href = attachment_url["href"] |> MediaProxy.url()
422 String.contains?(media_type, "image") -> "image"
423 String.contains?(media_type, "video") -> "video"
424 String.contains?(media_type, "audio") -> "audio"
428 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
431 id: to_string(attachment["id"] || hash_id),
437 description: attachment["name"],
438 pleroma: %{mime_type: media_type}
442 def render("context.json", %{activity: activity, activities: activities, user: user}) do
443 %{ancestors: ancestors, descendants: descendants} =
446 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
447 |> Map.put_new(:ancestors, [])
448 |> Map.put_new(:descendants, [])
451 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
452 descendants: render("index.json", for: user, activities: descendants, as: :activity)
456 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
457 object = Object.normalize(activity)
459 with nil <- replied_to_activities[object.data["inReplyTo"]] do
460 # If user didn't participate in the thread
461 Activity.get_in_reply_to_activity(activity)
465 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
466 object = Object.normalize(activity)
468 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
469 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
475 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
476 url = object.data["url"] || object.data["id"]
478 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
481 def render_content(object), do: object.data["content"] || ""
484 Builds a dictionary tags.
488 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
489 [{"name": "fediverse", "url": "/tag/fediverse"},
490 {"name": "nextcloud", "url": "/tag/nextcloud"}]
493 @spec build_tags(list(any())) :: list(map())
494 def build_tags(object_tags) when is_list(object_tags) do
496 |> Enum.filter(&is_binary/1)
497 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
500 def build_tags(_), do: []
505 Arguments: `nil` or list tuple of name and url.
511 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
512 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
515 @spec build_emojis(nil | list(tuple())) :: list(map())
516 def build_emojis(nil), do: []
518 def build_emojis(emojis) do
520 |> Enum.map(fn {name, url} ->
521 name = HTML.strip_tags(name)
528 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
532 defp present?(nil), do: false
533 defp present?(false), do: false
534 defp present?(_), do: true
536 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
537 do: id in pinned_activities
539 defp build_emoji_map(emoji, users, current_user) do
542 count: length(users),
543 me: !!(current_user && current_user.ap_id in users)