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]
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)
64 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
67 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
68 do: Utils.context_to_conversation_id(context)
70 defp get_context_id(_), do: nil
72 defp reblogged?(activity, user) do
73 object = Object.normalize(activity) || %{}
74 present?(user && user.ap_id in (object.data["announcements"] || []))
77 def render("index.json", opts) do
78 reading_user = opts[:for]
80 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
81 activities = Enum.filter(opts.activities, & &1)
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(&get_user(&1.data["actor"], false))
109 UserRelationship.view_relationships_option(reading_user, actors,
110 source_mutes_only: opts[:skip_relationships]
116 |> Map.put(:replied_to_activities, replied_to_activities)
117 |> Map.put(:parent_activities, parent_activities)
118 |> Map.put(:relationships, relationships_opt)
120 safe_render_many(activities, StatusView, "show.json", opts)
125 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
127 user = get_user(activity.data["actor"])
128 created_at = Utils.to_masto_date(activity.data["published"])
129 activity_object = Object.normalize(activity)
131 reblogged_parent_activity =
132 if opts[:parent_activities] do
133 Activity.Queries.find_by_object_ap_id(
134 opts[:parent_activities],
135 activity_object.data["id"]
138 Activity.create_by_object_ap_id(activity_object.data["id"])
139 |> Activity.with_preloaded_bookmark(opts[:for])
140 |> Activity.with_set_thread_muted_field(opts[:for])
144 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
145 reblogged = render("show.json", reblog_rendering_opts)
147 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
149 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
153 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
155 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
158 id: to_string(activity.id),
159 uri: activity_object.data["id"],
160 url: activity_object.data["id"],
162 AccountView.render("show.json", %{
165 relationships: opts[:relationships],
166 skip_relationships: opts[:skip_relationships]
169 in_reply_to_account_id: nil,
171 content: reblogged[:content] || "",
172 created_at: created_at,
176 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
177 favourited: present?(favorited),
178 bookmarked: present?(bookmarked),
180 pinned: pinned?(activity, user),
183 visibility: get_visibility(activity),
184 media_attachments: reblogged[:media_attachments] || [],
186 tags: reblogged[:tags] || [],
194 local: activity.local
199 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
200 object = Object.normalize(activity)
202 user = get_user(activity.data["actor"])
203 user_follower_address = user.follower_address
205 like_count = object.data["like_count"] || 0
206 announcement_count = object.data["announcement_count"] || 0
208 tags = object.data["tag"] || []
209 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
213 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
214 |> Enum.map(fn tag -> tag["href"] end)
217 (object.data["to"] ++ tag_mentions)
220 Pleroma.Constants.as_public() -> nil
221 ^user_follower_address -> nil
222 ap_id -> User.get_cached_by_ap_id(ap_id)
225 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
227 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
229 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
231 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
234 with true <- client_posted_this_activity,
235 %ActivityExpiration{scheduled_at: scheduled_at} <-
236 ActivityExpiration.get_by_activity_id(activity.id) do
244 is_nil(opts[:for]) -> false
245 is_boolean(activity.thread_muted?) -> activity.thread_muted?
246 true -> CommonAPI.thread_muted?(opts[:for], activity)
249 attachment_data = object.data["attachment"] || []
250 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
252 created_at = Utils.to_masto_date(object.data["published"])
254 reply_to = get_reply_to(activity, opts)
256 reply_to_user = reply_to && get_user(reply_to.data["actor"])
264 |> HTML.get_cached_scrubbed_html_for_activity(
265 User.html_filter_policy(opts[:for]),
272 |> HTML.get_cached_stripped_html_for_activity(
277 summary = object.data["summary"] || ""
279 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
283 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
285 object.data["url"] || object.data["external_url"] || object.data["id"]
288 direct_conversation_id =
289 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
290 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
291 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
292 Activity.direct_conversation_id(activity, for_user)
294 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
302 with %{data: %{"reactions" => emoji_reactions}} <- object do
303 Enum.map(emoji_reactions, fn [emoji, users] ->
306 count: length(users),
307 me: !!(opts[:for] && opts[:for].ap_id in users)
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", %{
333 relationships: opts[:relationships],
334 skip_relationships: opts[:skip_relationships]
336 in_reply_to_id: reply_to && to_string(reply_to.id),
337 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
340 content: content_html,
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
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 rich_media[:url] != nil do
386 URI.merge(page_url_data, URI.parse(rich_media[:url]))
391 page_url = page_url_data |> to_string
394 if rich_media[:image] != nil do
395 URI.merge(page_url_data, URI.parse(rich_media[:image]))
403 provider_name: page_url_data.host,
404 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
406 image: image_url |> MediaProxy.url(),
407 title: rich_media[:title] || "",
408 description: rich_media[:description] || "",
410 opengraph: rich_media
415 def render("card.json", _), do: nil
417 def render("attachment.json", %{attachment: attachment}) do
418 [attachment_url | _] = attachment["url"]
419 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
420 href = attachment_url["href"] |> MediaProxy.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),
439 description: attachment["name"],
440 pleroma: %{mime_type: media_type}
444 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
445 object = Object.normalize(activity)
447 user = get_user(activity.data["actor"])
448 created_at = Utils.to_masto_date(activity.data["published"])
452 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
453 created_at: created_at,
454 title: object.data["title"] |> HTML.strip_tags(),
455 artist: object.data["artist"] |> HTML.strip_tags(),
456 album: object.data["album"] |> HTML.strip_tags(),
457 length: object.data["length"]
461 def render("listens.json", opts) do
462 safe_render_many(opts.activities, StatusView, "listen.json", opts)
465 def render("context.json", %{activity: activity, activities: activities, user: user}) do
466 %{ancestors: ancestors, descendants: descendants} =
469 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
470 |> Map.put_new(:ancestors, [])
471 |> Map.put_new(:descendants, [])
474 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
475 descendants: render("index.json", for: user, activities: descendants, as: :activity)
479 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
480 object = Object.normalize(activity)
482 with nil <- replied_to_activities[object.data["inReplyTo"]] do
483 # If user didn't participate in the thread
484 Activity.get_in_reply_to_activity(activity)
488 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
489 object = Object.normalize(activity)
491 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
492 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
498 def render_content(%{data: %{"type" => object_type}} = object)
499 when object_type in ["Video", "Event", "Audio"] do
500 with name when not is_nil(name) and name != "" <- object.data["name"] do
501 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
503 _ -> object.data["content"] || ""
507 def render_content(%{data: %{"type" => object_type}} = object)
508 when object_type in ["Article", "Page"] do
509 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
510 url when is_bitstring(url) <- object.data["url"] do
511 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
513 _ -> object.data["content"] || ""
517 def render_content(object), do: object.data["content"] || ""
520 Builds a dictionary tags.
524 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
525 [{"name": "fediverse", "url": "/tag/fediverse"},
526 {"name": "nextcloud", "url": "/tag/nextcloud"}]
529 @spec build_tags(list(any())) :: list(map())
530 def build_tags(object_tags) when is_list(object_tags) do
531 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
533 Enum.reduce(object_tags, [], fn tag, tags ->
534 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
538 def build_tags(_), do: []
543 Arguments: `nil` or list tuple of name and url.
549 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
550 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
553 @spec build_emojis(nil | list(tuple())) :: list(map())
554 def build_emojis(nil), do: []
556 def build_emojis(emojis) do
558 |> Enum.map(fn {name, url} ->
559 name = HTML.strip_tags(name)
566 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
570 defp present?(nil), do: false
571 defp present?(false), do: false
572 defp present?(_), do: true
574 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
575 do: id in pinned_activities