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)
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,
111 source_mutes_only: opts[:skip_relationships]
117 |> Map.put(:replied_to_activities, replied_to_activities)
118 |> Map.put(:parent_activities, parent_activities)
119 |> Map.put(:relationships, relationships_opt)
121 safe_render_many(activities, StatusView, "show.json", opts)
126 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
128 user = get_user(activity.data["actor"])
129 created_at = Utils.to_masto_date(activity.data["published"])
130 activity_object = Object.normalize(activity)
132 reblogged_parent_activity =
133 if opts[:parent_activities] do
134 Activity.Queries.find_by_object_ap_id(
135 opts[:parent_activities],
136 activity_object.data["id"]
139 Activity.create_by_object_ap_id(activity_object.data["id"])
140 |> Activity.with_preloaded_bookmark(opts[:for])
141 |> Activity.with_set_thread_muted_field(opts[:for])
145 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
146 reblogged = render("show.json", reblog_rendering_opts)
148 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
150 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
154 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
156 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
159 id: to_string(activity.id),
160 uri: activity_object.data["id"],
161 url: activity_object.data["id"],
163 AccountView.render("show.json", %{
166 relationships: opts[:relationships],
167 skip_relationships: opts[:skip_relationships]
170 in_reply_to_account_id: nil,
172 content: reblogged[:content] || "",
173 created_at: created_at,
177 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
178 favourited: present?(favorited),
179 bookmarked: present?(bookmarked),
181 pinned: pinned?(activity, user),
184 visibility: get_visibility(activity),
185 media_attachments: reblogged[:media_attachments] || [],
187 tags: reblogged[:tags] || [],
195 local: activity.local
200 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
201 object = Object.normalize(activity)
203 user = get_user(activity.data["actor"])
204 user_follower_address = user.follower_address
206 like_count = object.data["like_count"] || 0
207 announcement_count = object.data["announcement_count"] || 0
209 tags = object.data["tag"] || []
210 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
214 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
215 |> Enum.map(fn tag -> tag["href"] end)
218 (object.data["to"] ++ tag_mentions)
221 Pleroma.Constants.as_public() -> nil
222 ^user_follower_address -> nil
223 ap_id -> User.get_cached_by_ap_id(ap_id)
226 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
228 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
230 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
232 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
235 with true <- client_posted_this_activity,
236 %ActivityExpiration{scheduled_at: scheduled_at} <-
237 ActivityExpiration.get_by_activity_id(activity.id) do
245 is_nil(opts[:for]) -> false
246 is_boolean(activity.thread_muted?) -> activity.thread_muted?
247 true -> CommonAPI.thread_muted?(opts[:for], activity)
250 attachment_data = object.data["attachment"] || []
251 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
253 created_at = Utils.to_masto_date(object.data["published"])
255 reply_to = get_reply_to(activity, opts)
257 reply_to_user = reply_to && get_user(reply_to.data["actor"])
265 |> HTML.get_cached_scrubbed_html_for_activity(
266 User.html_filter_policy(opts[:for]),
273 |> HTML.get_cached_stripped_html_for_activity(
278 summary = object.data["summary"] || ""
280 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
284 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
286 object.data["url"] || object.data["external_url"] || object.data["id"]
289 direct_conversation_id =
290 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
291 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
292 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
293 Activity.direct_conversation_id(activity, for_user)
295 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
303 with %{data: %{"reactions" => emoji_reactions}} <- object do
304 Enum.map(emoji_reactions, fn [emoji, users] ->
307 count: length(users),
308 me: !!(opts[:for] && opts[:for].ap_id in users)
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", %{
334 relationships: opts[:relationships],
335 skip_relationships: opts[:skip_relationships]
337 in_reply_to_id: reply_to && to_string(reply_to.id),
338 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
341 content: content_html,
342 created_at: created_at,
343 reblogs_count: announcement_count,
344 replies_count: object.data["repliesCount"] || 0,
345 favourites_count: like_count,
346 reblogged: reblogged?(activity, opts[:for]),
347 favourited: present?(favorited),
348 bookmarked: present?(bookmarked),
350 pinned: pinned?(activity, user),
351 sensitive: sensitive,
352 spoiler_text: summary,
353 visibility: get_visibility(object),
354 media_attachments: attachments,
355 poll: render(PollView, "show.json", object: object, for: opts[:for]),
357 tags: build_tags(tags),
363 emojis: build_emojis(object.data["emoji"]),
365 local: activity.local,
366 conversation_id: get_context_id(activity),
367 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
368 content: %{"text/plain" => content_plaintext},
369 spoiler_text: %{"text/plain" => summary},
370 expires_at: expires_at,
371 direct_conversation_id: direct_conversation_id,
372 thread_muted: thread_muted?,
373 emoji_reactions: emoji_reactions
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 rich_media[:url] != nil do
387 URI.merge(page_url_data, URI.parse(rich_media[:url]))
392 page_url = page_url_data |> to_string
395 if rich_media[:image] != nil do
396 URI.merge(page_url_data, URI.parse(rich_media[:image]))
404 provider_name: page_url_data.host,
405 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
407 image: image_url |> MediaProxy.url(),
408 title: rich_media[:title] || "",
409 description: rich_media[:description] || "",
411 opengraph: rich_media
416 def render("card.json", _), do: nil
418 def render("attachment.json", %{attachment: attachment}) do
419 [attachment_url | _] = attachment["url"]
420 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
421 href = attachment_url["href"] |> MediaProxy.url()
425 String.contains?(media_type, "image") -> "image"
426 String.contains?(media_type, "video") -> "video"
427 String.contains?(media_type, "audio") -> "audio"
431 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
434 id: to_string(attachment["id"] || hash_id),
440 description: attachment["name"],
441 pleroma: %{mime_type: media_type}
445 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
446 object = Object.normalize(activity)
448 user = get_user(activity.data["actor"])
449 created_at = Utils.to_masto_date(activity.data["published"])
453 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
454 created_at: created_at,
455 title: object.data["title"] |> HTML.strip_tags(),
456 artist: object.data["artist"] |> HTML.strip_tags(),
457 album: object.data["album"] |> HTML.strip_tags(),
458 length: object.data["length"]
462 def render("listens.json", opts) do
463 safe_render_many(opts.activities, StatusView, "listen.json", opts)
466 def render("context.json", %{activity: activity, activities: activities, user: user}) do
467 %{ancestors: ancestors, descendants: descendants} =
470 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
471 |> Map.put_new(:ancestors, [])
472 |> Map.put_new(:descendants, [])
475 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
476 descendants: render("index.json", for: user, activities: descendants, as: :activity)
480 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
481 object = Object.normalize(activity)
483 with nil <- replied_to_activities[object.data["inReplyTo"]] do
484 # If user didn't participate in the thread
485 Activity.get_in_reply_to_activity(activity)
489 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
490 object = Object.normalize(activity)
492 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
493 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
499 def render_content(%{data: %{"type" => object_type}} = object)
500 when object_type in ["Video", "Event", "Audio"] do
501 with name when not is_nil(name) and name != "" <- object.data["name"] do
502 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
504 _ -> object.data["content"] || ""
508 def render_content(%{data: %{"type" => object_type}} = object)
509 when object_type in ["Article", "Page"] do
510 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
511 url when is_bitstring(url) <- object.data["url"] do
512 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
514 _ -> object.data["content"] || ""
518 def render_content(object), do: object.data["content"] || ""
521 Builds a dictionary tags.
525 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
526 [{"name": "fediverse", "url": "/tag/fediverse"},
527 {"name": "nextcloud", "url": "/tag/nextcloud"}]
530 @spec build_tags(list(any())) :: list(map())
531 def build_tags(object_tags) when is_list(object_tags) do
533 |> Enum.filter(&is_binary/1)
534 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
537 def build_tags(_), do: []
542 Arguments: `nil` or list tuple of name and url.
548 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
549 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
552 @spec build_emojis(nil | list(tuple())) :: list(map())
553 def build_emojis(nil), do: []
555 def build_emojis(emojis) do
557 |> Enum.map(fn {name, url} ->
558 name = HTML.strip_tags(name)
565 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
569 defp present?(nil), do: false
570 defp present?(false), do: false
571 defp present?(_), do: true
573 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
574 do: id in pinned_activities