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 # This is a naive way to do this, just spawning a process per activity
27 # to fetch the preview. However it should be fine considering
28 # pagination is restricted to 40 activities at a time
29 defp fetch_rich_media_for_activities(activities) do
30 Enum.each(activities, fn activity ->
32 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
37 # TODO: Add cached version.
38 defp get_replied_to_activities([]), do: %{}
40 defp get_replied_to_activities(activities) do
43 %{data: %{"type" => "Create"}} = activity ->
44 object = Object.normalize(activity)
45 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
51 |> Activity.create_by_object_ap_id_with_object()
53 |> Enum.reduce(%{}, fn activity, acc ->
54 object = Object.normalize(activity)
55 if object, do: Map.put(acc, object.data["id"], activity), else: acc
59 def get_user(ap_id, fake_record_fallback \\ true) do
61 user = User.get_cached_by_ap_id(ap_id) ->
64 user = User.get_by_guessed_nickname(ap_id) ->
67 fake_record_fallback ->
68 # TODO: refactor (fake records is never a good idea)
69 User.error_user(ap_id)
76 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
79 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
80 do: Utils.context_to_conversation_id(context)
82 defp get_context_id(_), do: nil
84 defp reblogged?(activity, user) do
85 object = Object.normalize(activity) || %{}
86 present?(user && user.ap_id in (object.data["announcements"] || []))
89 def render("index.json", opts) do
90 reading_user = opts[:for]
92 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
93 activities = Enum.filter(opts.activities, & &1)
95 # Start fetching rich media before doing anything else, so that later calls to get the cards
96 # only block for timeout in the worst case, as opposed to
97 # length(activities_with_links) * timeout
98 fetch_rich_media_for_activities(activities)
99 replied_to_activities = get_replied_to_activities(activities)
103 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
104 |> Enum.map(&Object.normalize(&1).data["id"])
105 |> Activity.create_by_object_ap_id()
106 |> Activity.with_preloaded_object(:left)
107 |> Activity.with_preloaded_bookmark(reading_user)
108 |> Activity.with_set_thread_muted_field(reading_user)
113 Map.has_key?(opts, :relationships) ->
116 is_nil(reading_user) ->
117 UserRelationship.view_relationships_option(nil, [])
120 # Note: unresolved users are filtered out
122 (activities ++ parent_activities)
123 |> Enum.map(&get_user(&1.data["actor"], false))
126 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
131 |> Map.put(:replied_to_activities, replied_to_activities)
132 |> Map.put(:parent_activities, parent_activities)
133 |> Map.put(:relationships, relationships_opt)
135 safe_render_many(activities, StatusView, "show.json", opts)
140 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
142 user = get_user(activity.data["actor"])
143 created_at = Utils.to_masto_date(activity.data["published"])
144 activity_object = Object.normalize(activity)
146 reblogged_parent_activity =
147 if opts[:parent_activities] do
148 Activity.Queries.find_by_object_ap_id(
149 opts[:parent_activities],
150 activity_object.data["id"]
153 Activity.create_by_object_ap_id(activity_object.data["id"])
154 |> Activity.with_preloaded_bookmark(opts[:for])
155 |> Activity.with_set_thread_muted_field(opts[:for])
159 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
160 reblogged = render("show.json", reblog_rendering_opts)
162 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
164 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
168 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
170 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
173 id: to_string(activity.id),
174 uri: activity_object.data["id"],
175 url: activity_object.data["id"],
177 AccountView.render("show.json", %{
182 in_reply_to_account_id: nil,
184 content: reblogged[:content] || "",
185 created_at: created_at,
189 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
190 favourited: present?(favorited),
191 bookmarked: present?(bookmarked),
193 pinned: pinned?(activity, user),
196 visibility: get_visibility(activity),
197 media_attachments: reblogged[:media_attachments] || [],
199 tags: reblogged[:tags] || [],
207 local: activity.local
212 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
213 object = Object.normalize(activity)
215 user = get_user(activity.data["actor"])
216 user_follower_address = user.follower_address
218 like_count = object.data["like_count"] || 0
219 announcement_count = object.data["announcement_count"] || 0
221 tags = object.data["tag"] || []
222 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
226 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
227 |> Enum.map(fn tag -> tag["href"] end)
230 (object.data["to"] ++ tag_mentions)
233 Pleroma.Constants.as_public() -> nil
234 ^user_follower_address -> nil
235 ap_id -> User.get_cached_by_ap_id(ap_id)
238 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
240 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
242 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
244 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
247 with true <- client_posted_this_activity,
248 %ActivityExpiration{scheduled_at: scheduled_at} <-
249 ActivityExpiration.get_by_activity_id(activity.id) do
257 is_nil(opts[:for]) -> false
258 is_boolean(activity.thread_muted?) -> activity.thread_muted?
259 true -> CommonAPI.thread_muted?(opts[:for], activity)
262 attachment_data = object.data["attachment"] || []
263 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
265 created_at = Utils.to_masto_date(object.data["published"])
267 reply_to = get_reply_to(activity, opts)
269 reply_to_user = reply_to && get_user(reply_to.data["actor"])
277 |> HTML.get_cached_scrubbed_html_for_activity(
278 User.html_filter_policy(opts[:for]),
285 |> HTML.get_cached_stripped_html_for_activity(
290 summary = object.data["summary"] || ""
292 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
296 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
298 object.data["url"] || object.data["external_url"] || object.data["id"]
301 direct_conversation_id =
302 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
303 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
304 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
305 Activity.direct_conversation_id(activity, for_user)
307 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
315 with %{data: %{"reactions" => emoji_reactions}} <- object do
316 Enum.map(emoji_reactions, fn
317 [emoji, users] when is_list(users) ->
318 build_emoji_map(emoji, users, opts[:for])
320 {emoji, users} when is_list(users) ->
321 build_emoji_map(emoji, users, opts[:for])
326 |> Enum.reject(&is_nil/1)
331 # Status muted state (would do 1 request per status unless user mutes are preloaded)
334 UserRelationship.exists?(
335 get_in(opts, [:relationships, :user_relationships]),
339 fn for_user, user -> User.mutes?(for_user, user) end
343 id: to_string(activity.id),
344 uri: object.data["id"],
347 AccountView.render("show.json", %{
351 in_reply_to_id: reply_to && to_string(reply_to.id),
352 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
355 content: content_html,
356 text: opts[:with_source] && object.data["source"],
357 created_at: created_at,
358 reblogs_count: announcement_count,
359 replies_count: object.data["repliesCount"] || 0,
360 favourites_count: like_count,
361 reblogged: reblogged?(activity, opts[:for]),
362 favourited: present?(favorited),
363 bookmarked: present?(bookmarked),
365 pinned: pinned?(activity, user),
366 sensitive: sensitive,
367 spoiler_text: summary,
368 visibility: get_visibility(object),
369 media_attachments: attachments,
370 poll: render(PollView, "show.json", object: object, for: opts[:for]),
372 tags: build_tags(tags),
378 emojis: build_emojis(object.data["emoji"]),
380 local: activity.local,
381 conversation_id: get_context_id(activity),
382 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
383 content: %{"text/plain" => content_plaintext},
384 spoiler_text: %{"text/plain" => summary},
385 expires_at: expires_at,
386 direct_conversation_id: direct_conversation_id,
387 thread_muted: thread_muted?,
388 emoji_reactions: emoji_reactions,
389 parent_visible: visible_for_user?(reply_to, opts[:for])
394 def render("show.json", _) do
398 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
399 page_url_data = URI.parse(page_url)
402 if is_binary(rich_media["url"]) do
403 URI.merge(page_url_data, URI.parse(rich_media["url"]))
408 page_url = page_url_data |> to_string
411 if is_binary(rich_media["image"]) do
412 URI.merge(page_url_data, URI.parse(rich_media["image"]))
418 provider_name: page_url_data.host,
419 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
421 image: image_url |> MediaProxy.url(),
422 title: rich_media["title"] || "",
423 description: rich_media["description"] || "",
425 opengraph: rich_media
430 def render("card.json", _), do: nil
432 def render("attachment.json", %{attachment: attachment}) do
433 [attachment_url | _] = attachment["url"]
434 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
435 href = attachment_url["href"] |> MediaProxy.url()
439 String.contains?(media_type, "image") -> "image"
440 String.contains?(media_type, "video") -> "video"
441 String.contains?(media_type, "audio") -> "audio"
445 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
448 id: to_string(attachment["id"] || hash_id),
454 description: attachment["name"],
455 pleroma: %{mime_type: media_type}
459 def render("context.json", %{activity: activity, activities: activities, user: user}) do
460 %{ancestors: ancestors, descendants: descendants} =
463 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
464 |> Map.put_new(:ancestors, [])
465 |> Map.put_new(:descendants, [])
468 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
469 descendants: render("index.json", for: user, activities: descendants, as: :activity)
473 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
474 object = Object.normalize(activity)
476 with nil <- replied_to_activities[object.data["inReplyTo"]] do
477 # If user didn't participate in the thread
478 Activity.get_in_reply_to_activity(activity)
482 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
483 object = Object.normalize(activity)
485 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
486 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
492 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
493 url = object.data["url"] || object.data["id"]
495 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
498 def render_content(object), do: object.data["content"] || ""
501 Builds a dictionary tags.
505 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
506 [{"name": "fediverse", "url": "/tag/fediverse"},
507 {"name": "nextcloud", "url": "/tag/nextcloud"}]
510 @spec build_tags(list(any())) :: list(map())
511 def build_tags(object_tags) when is_list(object_tags) do
513 |> Enum.filter(&is_binary/1)
514 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
517 def build_tags(_), do: []
522 Arguments: `nil` or list tuple of name and url.
528 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
529 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
532 @spec build_emojis(nil | list(tuple())) :: list(map())
533 def build_emojis(nil), do: []
535 def build_emojis(emojis) do
537 |> Enum.map(fn {name, url} ->
538 name = HTML.strip_tags(name)
545 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
549 defp present?(nil), do: false
550 defp present?(false), do: false
551 defp present?(_), do: true
553 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
554 do: id in pinned_activities
556 defp build_emoji_map(emoji, users, current_user) do
559 count: length(users),
560 me: !!(current_user && current_user.ap_id in users)