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()
423 String.contains?(media_type, "image") -> "image"
424 String.contains?(media_type, "video") -> "video"
425 String.contains?(media_type, "audio") -> "audio"
429 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
432 id: to_string(attachment["id"] || hash_id),
438 description: attachment["name"],
439 pleroma: %{mime_type: media_type}
443 def render("context.json", %{activity: activity, activities: activities, user: user}) do
444 %{ancestors: ancestors, descendants: descendants} =
447 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
448 |> Map.put_new(:ancestors, [])
449 |> Map.put_new(:descendants, [])
452 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
453 descendants: render("index.json", for: user, activities: descendants, as: :activity)
457 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
458 object = Object.normalize(activity)
460 with nil <- replied_to_activities[object.data["inReplyTo"]] do
461 # If user didn't participate in the thread
462 Activity.get_in_reply_to_activity(activity)
466 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
467 object = Object.normalize(activity)
469 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
470 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
476 def render_content(%{data: %{"type" => object_type}} = object)
477 when object_type in ["Video", "Event", "Audio"] do
478 with name when not is_nil(name) and name != "" <- object.data["name"] do
479 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
481 _ -> object.data["content"] || ""
485 def render_content(%{data: %{"type" => object_type}} = object)
486 when object_type in ["Article", "Page"] do
487 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
488 url when is_bitstring(url) <- object.data["url"] do
489 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
491 _ -> object.data["content"] || ""
495 def render_content(object), do: object.data["content"] || ""
498 Builds a dictionary tags.
502 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
503 [{"name": "fediverse", "url": "/tag/fediverse"},
504 {"name": "nextcloud", "url": "/tag/nextcloud"}]
507 @spec build_tags(list(any())) :: list(map())
508 def build_tags(object_tags) when is_list(object_tags) do
510 |> Enum.filter(&is_binary/1)
511 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
514 def build_tags(_), do: []
519 Arguments: `nil` or list tuple of name and url.
525 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
526 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
529 @spec build_emojis(nil | list(tuple())) :: list(map())
530 def build_emojis(nil), do: []
532 def build_emojis(emojis) do
534 |> Enum.map(fn {name, url} ->
535 name = HTML.strip_tags(name)
542 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
546 defp present?(nil), do: false
547 defp present?(false), do: false
548 defp present?(_), do: true
550 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
551 do: id in pinned_activities
553 defp build_emoji_map(emoji, users, current_user) do
556 count: length(users),
557 me: !!(current_user && current_user.ap_id in users)