1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 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
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
23 alias Pleroma.Web.PleromaAPI.EmojiReactionController
25 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
27 # This is a naive way to do this, just spawning a process per activity
28 # to fetch the preview. However it should be fine considering
29 # pagination is restricted to 40 activities at a time
30 defp fetch_rich_media_for_activities(activities) do
31 Enum.each(activities, fn activity ->
33 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
38 # TODO: Add cached version.
39 defp get_replied_to_activities([]), do: %{}
41 defp get_replied_to_activities(activities) do
44 %{data: %{"type" => "Create"}} = activity ->
45 object = Object.normalize(activity, fetch: false)
46 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
52 |> Activity.create_by_object_ap_id_with_object()
54 |> Enum.reduce(%{}, fn activity, acc ->
55 object = Object.normalize(activity, fetch: false)
56 if object, do: Map.put(acc, object.data["id"], activity), else: acc
60 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
63 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
64 do: Utils.context_to_conversation_id(context)
66 defp get_context_id(_), do: nil
68 # Check if the user reblogged this status
69 defp reblogged?(activity, %User{ap_id: ap_id}) do
70 with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
71 Object.normalize(activity, fetch: false) do
72 ap_id in announcements
78 # False if the user is logged out
79 defp reblogged?(_activity, _user), do: false
81 def render("index.json", opts) do
82 reading_user = opts[:for]
84 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
85 activities = Enum.filter(opts.activities, & &1)
87 # Start fetching rich media before doing anything else, so that later calls to get the cards
88 # only block for timeout in the worst case, as opposed to
89 # length(activities_with_links) * timeout
90 fetch_rich_media_for_activities(activities)
91 replied_to_activities = get_replied_to_activities(activities)
95 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
96 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
97 |> Activity.create_by_object_ap_id()
98 |> Activity.with_preloaded_object(:left)
99 |> Activity.with_preloaded_bookmark(reading_user)
100 |> Activity.with_set_thread_muted_field(reading_user)
105 Map.has_key?(opts, :relationships) ->
108 is_nil(reading_user) ->
109 UserRelationship.view_relationships_option(nil, [])
112 # Note: unresolved users are filtered out
114 (activities ++ parent_activities)
115 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
118 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
123 |> Map.put(:replied_to_activities, replied_to_activities)
124 |> Map.put(:parent_activities, parent_activities)
125 |> Map.put(:relationships, relationships_opt)
127 safe_render_many(activities, StatusView, "show.json", opts)
132 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
134 user = CommonAPI.get_user(activity.data["actor"])
135 created_at = Utils.to_masto_date(activity.data["published"])
136 object = Object.normalize(activity, fetch: false)
138 reblogged_parent_activity =
139 if opts[:parent_activities] do
140 Activity.Queries.find_by_object_ap_id(
141 opts[:parent_activities],
145 Activity.create_by_object_ap_id(object.data["id"])
146 |> Activity.with_preloaded_bookmark(opts[:for])
147 |> Activity.with_set_thread_muted_field(opts[:for])
151 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
152 reblogged = render("show.json", reblog_rendering_opts)
154 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
156 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
160 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
162 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
164 {pinned?, pinned_at} = pin_data(object, user)
167 id: to_string(activity.id),
168 uri: object.data["id"],
169 url: object.data["id"],
171 AccountView.render("show.json", %{
176 in_reply_to_account_id: nil,
178 content: reblogged[:content] || "",
179 created_at: created_at,
183 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
184 favourited: present?(favorited),
185 bookmarked: present?(bookmarked),
190 visibility: get_visibility(activity),
191 media_attachments: reblogged[:media_attachments] || [],
193 tags: reblogged[:tags] || [],
194 application: build_application(object.data["generator"]),
198 local: activity.local,
204 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
205 object = Object.normalize(activity, fetch: false)
207 user = CommonAPI.get_user(activity.data["actor"])
208 user_follower_address = user.follower_address
210 like_count = object.data["like_count"] || 0
211 announcement_count = object.data["announcement_count"] || 0
213 hashtags = Object.hashtags(object)
214 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
216 tags = Object.tags(object)
220 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
221 |> Enum.map(fn tag -> tag["href"] end)
224 (object.data["to"] ++ tag_mentions)
227 Pleroma.Constants.as_public() -> nil
228 ^user_follower_address -> nil
229 ap_id -> User.get_cached_by_ap_id(ap_id)
232 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
234 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
236 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
238 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
241 with true <- client_posted_this_activity,
242 %Oban.Job{scheduled_at: scheduled_at} <-
243 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
251 is_nil(opts[:for]) -> false
252 is_boolean(activity.thread_muted?) -> activity.thread_muted?
253 true -> CommonAPI.thread_muted?(opts[:for], activity)
256 attachment_data = object.data["attachment"] || []
257 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
259 created_at = Utils.to_masto_date(object.data["published"])
261 reply_to = get_reply_to(activity, opts)
263 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
271 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
272 User.html_filter_policy(opts[:for]),
279 |> Activity.HTML.get_cached_stripped_html_for_activity(
284 summary = object.data["summary"] || ""
286 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
290 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
292 object.data["url"] || object.data["external_url"] || object.data["id"]
295 direct_conversation_id =
296 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
297 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
298 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
299 Activity.direct_conversation_id(activity, for_user)
301 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
309 |> Map.get("reactions", [])
310 |> EmojiReactionController.filter_allowed_users(
312 Map.get(opts, :with_muted, false)
314 |> Stream.map(fn {emoji, users, url} ->
315 build_emoji_map(emoji, users, url, opts[:for])
319 # Status muted state (would do 1 request per status unless user mutes are preloaded)
322 UserRelationship.exists?(
323 get_in(opts, [:relationships, :user_relationships]),
327 fn for_user, user -> User.mutes?(for_user, user) end
330 {pinned?, pinned_at} = pin_data(object, user)
333 id: to_string(activity.id),
334 uri: object.data["id"],
337 AccountView.render("show.json", %{
341 in_reply_to_id: reply_to && to_string(reply_to.id),
342 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
345 content: content_html,
346 text: opts[:with_source] && object.data["source"],
347 created_at: created_at,
348 reblogs_count: announcement_count,
349 replies_count: object.data["repliesCount"] || 0,
350 favourites_count: like_count,
351 reblogged: reblogged?(activity, opts[:for]),
352 favourited: present?(favorited),
353 bookmarked: present?(bookmarked),
356 sensitive: sensitive,
357 spoiler_text: summary,
358 visibility: get_visibility(object),
359 media_attachments: attachments,
360 poll: render(PollView, "show.json", object: object, for: opts[:for]),
362 tags: build_tags(tags),
363 application: build_application(object.data["generator"]),
365 emojis: build_emojis(object.data["emoji"]),
367 local: activity.local,
368 conversation_id: get_context_id(activity),
369 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
370 content: %{"text/plain" => content_plaintext},
371 spoiler_text: %{"text/plain" => summary},
372 expires_at: expires_at,
373 direct_conversation_id: direct_conversation_id,
374 thread_muted: thread_muted?,
375 emoji_reactions: emoji_reactions,
376 parent_visible: visible_for_user?(reply_to, opts[:for]),
382 def render("show.json", _) do
386 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
387 page_url_data = URI.parse(page_url)
390 if is_binary(rich_media["url"]) do
391 URI.merge(page_url_data, URI.parse(rich_media["url"]))
396 page_url = page_url_data |> to_string
399 if is_binary(rich_media["image"]) do
400 URI.parse(rich_media["image"])
405 image_url = build_image_url(image_url_data, page_url_data)
409 provider_name: page_url_data.host,
410 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
412 image: image_url |> MediaProxy.url(),
413 title: rich_media["title"] || "",
414 description: rich_media["description"] || "",
416 opengraph: rich_media
421 def render("card.json", _), do: nil
423 def render("attachment.json", %{attachment: attachment}) do
424 [attachment_url | _] = attachment["url"]
425 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
426 href = attachment_url["href"] |> MediaProxy.url()
427 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
428 meta = render("attachment_meta.json", %{attachment: attachment})
432 String.contains?(media_type, "image") -> "image"
433 String.contains?(media_type, "video") -> "video"
434 String.contains?(media_type, "audio") -> "audio"
438 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
441 id: to_string(attachment["id"] || hash_id),
444 preview_url: href_preview,
447 description: attachment["name"],
448 pleroma: %{mime_type: media_type},
449 blurhash: attachment["blurhash"]
451 |> Maps.put_if_present(:meta, meta)
454 def render("attachment_meta.json", %{
455 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
457 when is_integer(width) and is_integer(height) do
462 aspect: width / height
467 def render("attachment_meta.json", _), do: nil
469 def render("context.json", %{activity: activity, activities: activities, user: user}) do
470 %{ancestors: ancestors, descendants: descendants} =
473 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
474 |> Map.put_new(:ancestors, [])
475 |> Map.put_new(:descendants, [])
478 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
479 descendants: render("index.json", for: user, activities: descendants, as: :activity)
483 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
484 object = Object.normalize(activity, fetch: false)
486 with nil <- replied_to_activities[object.data["inReplyTo"]] do
487 # If user didn't participate in the thread
488 Activity.get_in_reply_to_activity(activity)
492 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
493 object = Object.normalize(activity, fetch: false)
495 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
496 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
502 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
503 url = object.data["url"] || object.data["id"]
505 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
508 def render_content(object), do: object.data["content"] || ""
511 Builds a dictionary tags.
515 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
516 [{"name": "fediverse", "url": "/tag/fediverse"},
517 {"name": "nextcloud", "url": "/tag/nextcloud"}]
520 @spec build_tags(list(any())) :: list(map())
521 def build_tags(object_tags) when is_list(object_tags) do
523 |> Enum.filter(&is_binary/1)
524 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
527 def build_tags(_), do: []
532 Arguments: `nil` or list tuple of name and url.
538 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
539 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
542 @spec build_emojis(nil | list(tuple())) :: list(map())
543 def build_emojis(nil), do: []
545 def build_emojis(emojis) do
547 |> Enum.map(fn {name, url} ->
548 name = HTML.strip_tags(name)
555 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
559 defp present?(nil), do: false
560 defp present?(false), do: false
561 defp present?(_), do: true
563 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
564 if pinned_at = pinned_objects[object_id] do
565 {true, Utils.to_masto_date(pinned_at)}
571 defp build_emoji_map(emoji, users, url, current_user) do
574 count: length(users),
576 me: !!(current_user && current_user.ap_id in users)
580 @spec build_application(map() | nil) :: map() | nil
581 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
582 do: %{name: name, website: url}
584 defp build_application(_), do: nil
586 # Workaround for Elixir issue #10771
587 # Avoid applying URI.merge unless necessary
588 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
589 # when Elixir 1.12 is the minimum supported version
590 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
591 defp build_image_url(
592 %URI{scheme: image_scheme, host: image_host} = image_url_data,
593 %URI{} = _page_url_data
595 when not is_nil(image_scheme) and not is_nil(image_host) do
596 image_url_data |> to_string
599 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
600 URI.merge(page_url_data, image_url_data) |> to_string
603 defp build_image_url(_, _), do: nil