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, source_mutes_only: true)
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", %{
164 skip_relationships: true
167 in_reply_to_account_id: nil,
169 content: reblogged[:content] || "",
170 created_at: created_at,
174 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
175 favourited: present?(favorited),
176 bookmarked: present?(bookmarked),
178 pinned: pinned?(activity, user),
181 visibility: get_visibility(activity),
182 media_attachments: reblogged[:media_attachments] || [],
184 tags: reblogged[:tags] || [],
192 local: activity.local
197 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
198 object = Object.normalize(activity)
200 user = get_user(activity.data["actor"])
201 user_follower_address = user.follower_address
203 like_count = object.data["like_count"] || 0
204 announcement_count = object.data["announcement_count"] || 0
206 tags = object.data["tag"] || []
207 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
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 %ActivityExpiration{scheduled_at: scheduled_at} <-
234 ActivityExpiration.get_by_activity_id(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 && 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) ->
300 with %{data: %{"reactions" => emoji_reactions}} <- object do
301 Enum.map(emoji_reactions, fn [emoji, users] ->
304 count: length(users),
305 me: !!(opts[:for] && opts[:for].ap_id in users)
312 # Status muted state (would do 1 request per status unless user mutes are preloaded)
315 UserRelationship.exists?(
316 get_in(opts, [:relationships, :user_relationships]),
320 fn for_user, user -> User.mutes?(for_user, user) end
324 id: to_string(activity.id),
325 uri: object.data["id"],
328 AccountView.render("show.json", %{
331 skip_relationships: true
333 in_reply_to_id: reply_to && to_string(reply_to.id),
334 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
337 content: content_html,
338 created_at: created_at,
339 reblogs_count: announcement_count,
340 replies_count: object.data["repliesCount"] || 0,
341 favourites_count: like_count,
342 reblogged: reblogged?(activity, opts[:for]),
343 favourited: present?(favorited),
344 bookmarked: present?(bookmarked),
346 pinned: pinned?(activity, user),
347 sensitive: sensitive,
348 spoiler_text: summary,
349 visibility: get_visibility(object),
350 media_attachments: attachments,
351 poll: render(PollView, "show.json", object: object, for: opts[:for]),
353 tags: build_tags(tags),
359 emojis: build_emojis(object.data["emoji"]),
361 local: activity.local,
362 conversation_id: get_context_id(activity),
363 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
364 content: %{"text/plain" => content_plaintext},
365 spoiler_text: %{"text/plain" => summary},
366 expires_at: expires_at,
367 direct_conversation_id: direct_conversation_id,
368 thread_muted: thread_muted?,
369 emoji_reactions: emoji_reactions
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 rich_media[:url] != nil do
383 URI.merge(page_url_data, URI.parse(rich_media[:url]))
388 page_url = page_url_data |> to_string
391 if rich_media[:image] != nil do
392 URI.merge(page_url_data, URI.parse(rich_media[:image]))
400 provider_name: page_url_data.host,
401 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
403 image: image_url |> MediaProxy.url(),
404 title: rich_media[:title] || "",
405 description: rich_media[:description] || "",
407 opengraph: rich_media
412 def render("card.json", _), do: nil
414 def render("attachment.json", %{attachment: attachment}) do
415 [attachment_url | _] = attachment["url"]
416 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
417 href = attachment_url["href"] |> MediaProxy.url()
421 String.contains?(media_type, "image") -> "image"
422 String.contains?(media_type, "video") -> "video"
423 String.contains?(media_type, "audio") -> "audio"
427 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
430 id: to_string(attachment["id"] || hash_id),
436 description: attachment["name"],
437 pleroma: %{mime_type: media_type}
441 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
442 object = Object.normalize(activity)
444 user = get_user(activity.data["actor"])
445 created_at = Utils.to_masto_date(activity.data["published"])
449 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
450 created_at: created_at,
451 title: object.data["title"] |> HTML.strip_tags(),
452 artist: object.data["artist"] |> HTML.strip_tags(),
453 album: object.data["album"] |> HTML.strip_tags(),
454 length: object.data["length"]
458 def render("listens.json", opts) do
459 safe_render_many(opts.activities, StatusView, "listen.json", opts)
462 def render("context.json", %{activity: activity, activities: activities, user: user}) do
463 %{ancestors: ancestors, descendants: descendants} =
466 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
467 |> Map.put_new(:ancestors, [])
468 |> Map.put_new(:descendants, [])
471 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
472 descendants: render("index.json", for: user, activities: descendants, as: :activity)
476 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
477 object = Object.normalize(activity)
479 with nil <- replied_to_activities[object.data["inReplyTo"]] do
480 # If user didn't participate in the thread
481 Activity.get_in_reply_to_activity(activity)
485 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
486 object = Object.normalize(activity)
488 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
489 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
495 def render_content(%{data: %{"type" => object_type}} = object)
496 when object_type in ["Video", "Event", "Audio"] do
497 with name when not is_nil(name) and name != "" <- object.data["name"] do
498 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
500 _ -> object.data["content"] || ""
504 def render_content(%{data: %{"type" => object_type}} = object)
505 when object_type in ["Article", "Page"] do
506 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
507 url when is_bitstring(url) <- object.data["url"] do
508 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
510 _ -> object.data["content"] || ""
514 def render_content(object), do: object.data["content"] || ""
517 Builds a dictionary tags.
521 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
522 [{"name": "fediverse", "url": "/tag/fediverse"},
523 {"name": "nextcloud", "url": "/tag/nextcloud"}]
526 @spec build_tags(list(any())) :: list(map())
527 def build_tags(object_tags) when is_list(object_tags) do
529 |> Enum.filter(&is_binary/1)
530 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
533 def build_tags(_), do: []
538 Arguments: `nil` or list tuple of name and url.
544 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
545 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
548 @spec build_emojis(nil | list(tuple())) :: list(map())
549 def build_emojis(nil), do: []
551 def build_emojis(emojis) do
553 |> Enum.map(fn {name, url} ->
554 name = HTML.strip_tags(name)
561 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
565 defp present?(nil), do: false
566 defp present?(false), do: false
567 defp present?(_), do: true
569 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
570 do: id in pinned_activities