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 # TODO: Add cached version.
27 defp get_replied_to_activities([]), do: %{}
29 defp get_replied_to_activities(activities) do
32 %{data: %{"type" => "Create"}} = activity ->
33 object = Object.normalize(activity)
34 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
40 |> Activity.create_by_object_ap_id_with_object()
42 |> Enum.reduce(%{}, fn activity, acc ->
43 object = Object.normalize(activity)
44 if object, do: Map.put(acc, object.data["id"], activity), else: acc
48 def get_user(ap_id, fake_record_fallback \\ true) do
50 user = User.get_cached_by_ap_id(ap_id) ->
53 user = User.get_by_guessed_nickname(ap_id) ->
56 fake_record_fallback ->
57 # TODO: refactor (fake records is never a good idea)
58 User.error_user(ap_id)
65 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
68 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
69 do: Utils.context_to_conversation_id(context)
71 defp get_context_id(_), do: nil
73 defp reblogged?(activity, user) do
74 object = Object.normalize(activity) || %{}
75 present?(user && user.ap_id in (object.data["announcements"] || []))
78 def render("index.json", opts) do
79 reading_user = opts[:for]
81 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
82 activities = Enum.filter(opts.activities, & &1)
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).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(&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 = get_user(activity.data["actor"])
127 created_at = Utils.to_masto_date(activity.data["published"])
128 activity_object = Object.normalize(activity)
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] || [],
191 local: activity.local
196 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
197 object = Object.normalize(activity)
199 user = get_user(activity.data["actor"])
200 user_follower_address = user.follower_address
202 like_count = object.data["like_count"] || 0
203 announcement_count = object.data["announcement_count"] || 0
205 tags = object.data["tag"] || []
206 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
210 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
211 |> Enum.map(fn tag -> tag["href"] end)
214 (object.data["to"] ++ tag_mentions)
217 Pleroma.Constants.as_public() -> nil
218 ^user_follower_address -> nil
219 ap_id -> User.get_cached_by_ap_id(ap_id)
222 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
224 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
226 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
228 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
231 with true <- client_posted_this_activity,
232 %ActivityExpiration{scheduled_at: scheduled_at} <-
233 ActivityExpiration.get_by_activity_id(activity.id) do
241 is_nil(opts[:for]) -> false
242 is_boolean(activity.thread_muted?) -> activity.thread_muted?
243 true -> CommonAPI.thread_muted?(opts[:for], activity)
246 attachment_data = object.data["attachment"] || []
247 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
249 created_at = Utils.to_masto_date(object.data["published"])
251 reply_to = get_reply_to(activity, opts)
253 reply_to_user = reply_to && get_user(reply_to.data["actor"])
261 |> HTML.get_cached_scrubbed_html_for_activity(
262 User.html_filter_policy(opts[:for]),
269 |> HTML.get_cached_stripped_html_for_activity(
274 summary = object.data["summary"] || ""
276 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
280 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
282 object.data["url"] || object.data["external_url"] || object.data["id"]
285 direct_conversation_id =
286 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
287 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
288 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
289 Activity.direct_conversation_id(activity, for_user)
291 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
299 with %{data: %{"reactions" => emoji_reactions}} <- object do
300 Enum.map(emoji_reactions, fn
301 [emoji, users] when is_list(users) ->
302 build_emoji_map(emoji, users, opts[:for])
304 {emoji, users} when is_list(users) ->
305 build_emoji_map(emoji, users, opts[:for])
310 |> Enum.reject(&is_nil/1)
315 # Status muted state (would do 1 request per status unless user mutes are preloaded)
318 UserRelationship.exists?(
319 get_in(opts, [:relationships, :user_relationships]),
323 fn for_user, user -> User.mutes?(for_user, user) end
327 id: to_string(activity.id),
328 uri: object.data["id"],
331 AccountView.render("show.json", %{
335 in_reply_to_id: reply_to && to_string(reply_to.id),
336 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
339 content: content_html,
340 text: opts[:with_source] && object.data["source"],
341 created_at: created_at,
342 reblogs_count: announcement_count,
343 replies_count: object.data["repliesCount"] || 0,
344 favourites_count: like_count,
345 reblogged: reblogged?(activity, opts[:for]),
346 favourited: present?(favorited),
347 bookmarked: present?(bookmarked),
349 pinned: pinned?(activity, user),
350 sensitive: sensitive,
351 spoiler_text: summary,
352 visibility: get_visibility(object),
353 media_attachments: attachments,
354 poll: render(PollView, "show.json", object: object, for: opts[:for]),
356 tags: build_tags(tags),
362 emojis: build_emojis(object.data["emoji"]),
364 local: activity.local,
365 conversation_id: get_context_id(activity),
366 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
367 content: %{"text/plain" => content_plaintext},
368 spoiler_text: %{"text/plain" => summary},
369 expires_at: expires_at,
370 direct_conversation_id: direct_conversation_id,
371 thread_muted: thread_muted?,
372 emoji_reactions: emoji_reactions,
373 parent_visible: visible_for_user?(reply_to, opts[:for])
378 def render("show.json", _) do
382 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
383 page_url_data = URI.parse(page_url)
386 if is_binary(rich_media["url"]) do
387 URI.merge(page_url_data, URI.parse(rich_media["url"]))
392 page_url = page_url_data |> to_string
395 if is_binary(rich_media["image"]) do
396 URI.merge(page_url_data, URI.parse(rich_media["image"]))
402 provider_name: page_url_data.host,
403 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
405 image: image_url |> MediaProxy.url(),
406 title: rich_media["title"] || "",
407 description: rich_media["description"] || "",
409 opengraph: rich_media
414 def render("card.json", _), do: nil
416 def render("attachment.json", %{attachment: attachment}) do
417 [attachment_url | _] = attachment["url"]
418 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
419 href = attachment_url["href"] |> MediaProxy.url()
420 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
424 String.contains?(media_type, "image") -> "image"
425 String.contains?(media_type, "video") -> "video"
426 String.contains?(media_type, "audio") -> "audio"
430 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
433 id: to_string(attachment["id"] || hash_id),
436 preview_url: href_preview,
439 description: attachment["name"],
440 pleroma: %{mime_type: media_type}
444 def render("context.json", %{activity: activity, activities: activities, user: user}) do
445 %{ancestors: ancestors, descendants: descendants} =
448 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
449 |> Map.put_new(:ancestors, [])
450 |> Map.put_new(:descendants, [])
453 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
454 descendants: render("index.json", for: user, activities: descendants, as: :activity)
458 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
459 object = Object.normalize(activity)
461 with nil <- replied_to_activities[object.data["inReplyTo"]] do
462 # If user didn't participate in the thread
463 Activity.get_in_reply_to_activity(activity)
467 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
468 object = Object.normalize(activity)
470 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
471 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
477 def render_content(%{data: %{"type" => object_type}} = object)
478 when object_type in ["Video", "Event", "Audio"] do
479 with name when not is_nil(name) and name != "" <- object.data["name"] do
480 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
482 _ -> object.data["content"] || ""
486 def render_content(%{data: %{"type" => object_type}} = object)
487 when object_type in ["Article", "Page"] do
488 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
489 url when is_bitstring(url) <- object.data["url"] do
490 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
492 _ -> object.data["content"] || ""
496 def render_content(object), do: object.data["content"] || ""
499 Builds a dictionary tags.
503 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
504 [{"name": "fediverse", "url": "/tag/fediverse"},
505 {"name": "nextcloud", "url": "/tag/nextcloud"}]
508 @spec build_tags(list(any())) :: list(map())
509 def build_tags(object_tags) when is_list(object_tags) do
511 |> Enum.filter(&is_binary/1)
512 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
515 def build_tags(_), do: []
520 Arguments: `nil` or list tuple of name and url.
526 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
527 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
530 @spec build_emojis(nil | list(tuple())) :: list(map())
531 def build_emojis(nil), do: []
533 def build_emojis(emojis) do
535 |> Enum.map(fn {name, url} ->
536 name = HTML.strip_tags(name)
543 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
547 defp present?(nil), do: false
548 defp present?(false), do: false
549 defp present?(_), do: true
551 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
552 do: id in pinned_activities
554 defp build_emoji_map(emoji, users, current_user) do
557 count: length(users),
558 me: !!(current_user && current_user.ap_id in users)