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 defp get_user(ap_id) do
50 user = User.get_cached_by_ap_id(ap_id) ->
53 user = User.get_by_guessed_nickname(ap_id) ->
57 User.error_user(ap_id)
61 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
64 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
65 do: Utils.context_to_conversation_id(context)
67 defp get_context_id(_), do: nil
69 defp reblogged?(activity, user) do
70 object = Object.normalize(activity) || %{}
71 present?(user && user.ap_id in (object.data["announcements"] || []))
74 def render("index.json", opts) do
75 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
76 activities = Enum.filter(opts.activities, & &1)
77 replied_to_activities = get_replied_to_activities(activities)
81 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
82 |> Enum.map(&Object.normalize(&1).data["id"])
83 |> Activity.create_by_object_ap_id()
84 |> Activity.with_preloaded_object(:left)
85 |> Activity.with_preloaded_bookmark(opts[:for])
86 |> Activity.with_set_thread_muted_field(opts[:for])
91 Map.has_key?(opts, :relationships) ->
95 UserRelationship.view_relationships_option(nil, [])
98 actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"]))
100 UserRelationship.view_relationships_option(opts[:for], actors,
101 source_mutes_only: opts[:skip_relationships]
107 |> Map.put(:replied_to_activities, replied_to_activities)
108 |> Map.put(:parent_activities, parent_activities)
109 |> Map.put(:relationships, relationships_opt)
111 safe_render_many(activities, StatusView, "show.json", opts)
116 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
118 user = get_user(activity.data["actor"])
119 created_at = Utils.to_masto_date(activity.data["published"])
120 activity_object = Object.normalize(activity)
122 reblogged_parent_activity =
123 if opts[:parent_activities] do
124 Activity.Queries.find_by_object_ap_id(
125 opts[:parent_activities],
126 activity_object.data["id"]
129 Activity.create_by_object_ap_id(activity_object.data["id"])
130 |> Activity.with_preloaded_bookmark(opts[:for])
131 |> Activity.with_set_thread_muted_field(opts[:for])
135 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
136 reblogged = render("show.json", reblog_rendering_opts)
138 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
140 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
144 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
146 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
149 id: to_string(activity.id),
150 uri: activity_object.data["id"],
151 url: activity_object.data["id"],
153 AccountView.render("show.json", %{
156 relationships: opts[:relationships],
157 skip_relationships: opts[:skip_relationships]
160 in_reply_to_account_id: nil,
162 content: reblogged[:content] || "",
163 created_at: created_at,
167 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
168 favourited: present?(favorited),
169 bookmarked: present?(bookmarked),
171 pinned: pinned?(activity, user),
174 visibility: get_visibility(activity),
175 media_attachments: reblogged[:media_attachments] || [],
177 tags: reblogged[:tags] || [],
185 local: activity.local
190 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
191 object = Object.normalize(activity)
193 user = get_user(activity.data["actor"])
194 user_follower_address = user.follower_address
196 like_count = object.data["like_count"] || 0
197 announcement_count = object.data["announcement_count"] || 0
199 tags = object.data["tag"] || []
200 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
204 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
205 |> Enum.map(fn tag -> tag["href"] end)
208 (object.data["to"] ++ tag_mentions)
211 Pleroma.Constants.as_public() -> nil
212 ^user_follower_address -> nil
213 ap_id -> User.get_cached_by_ap_id(ap_id)
216 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
218 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
220 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
222 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
225 with true <- client_posted_this_activity,
226 %ActivityExpiration{scheduled_at: scheduled_at} <-
227 ActivityExpiration.get_by_activity_id(activity.id) do
235 is_nil(opts[:for]) -> false
236 is_boolean(activity.thread_muted?) -> activity.thread_muted?
237 true -> CommonAPI.thread_muted?(opts[:for], activity)
240 attachment_data = object.data["attachment"] || []
241 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
243 created_at = Utils.to_masto_date(object.data["published"])
245 reply_to = get_reply_to(activity, opts)
247 reply_to_user = reply_to && get_user(reply_to.data["actor"])
255 |> HTML.get_cached_scrubbed_html_for_activity(
256 User.html_filter_policy(opts[:for]),
263 |> HTML.get_cached_stripped_html_for_activity(
268 summary = object.data["summary"] || ""
270 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
274 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
276 object.data["url"] || object.data["external_url"] || object.data["id"]
279 direct_conversation_id =
280 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
281 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
282 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
283 Activity.direct_conversation_id(activity, for_user)
285 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
293 with %{data: %{"reactions" => emoji_reactions}} <- object do
294 Enum.map(emoji_reactions, fn [emoji, users] ->
297 count: length(users),
298 me: !!(opts[:for] && opts[:for].ap_id in users)
305 # Status muted state (would do 1 request per status unless user mutes are preloaded)
308 UserRelationship.exists?(
309 get_in(opts, [:relationships, :user_relationships]),
313 fn for_user, user -> User.mutes?(for_user, user) end
317 id: to_string(activity.id),
318 uri: object.data["id"],
321 AccountView.render("show.json", %{
324 relationships: opts[:relationships],
325 skip_relationships: opts[:skip_relationships]
327 in_reply_to_id: reply_to && to_string(reply_to.id),
328 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
331 content: content_html,
332 created_at: created_at,
333 reblogs_count: announcement_count,
334 replies_count: object.data["repliesCount"] || 0,
335 favourites_count: like_count,
336 reblogged: reblogged?(activity, opts[:for]),
337 favourited: present?(favorited),
338 bookmarked: present?(bookmarked),
340 pinned: pinned?(activity, user),
341 sensitive: sensitive,
342 spoiler_text: summary,
343 visibility: get_visibility(object),
344 media_attachments: attachments,
345 poll: render(PollView, "show.json", object: object, for: opts[:for]),
347 tags: build_tags(tags),
353 emojis: build_emojis(object.data["emoji"]),
355 local: activity.local,
356 conversation_id: get_context_id(activity),
357 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
358 content: %{"text/plain" => content_plaintext},
359 spoiler_text: %{"text/plain" => summary},
360 expires_at: expires_at,
361 direct_conversation_id: direct_conversation_id,
362 thread_muted: thread_muted?,
363 emoji_reactions: emoji_reactions
368 def render("show.json", _) do
372 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
373 page_url_data = URI.parse(page_url)
376 if rich_media[:url] != nil do
377 URI.merge(page_url_data, URI.parse(rich_media[:url]))
382 page_url = page_url_data |> to_string
385 if rich_media[:image] != nil do
386 URI.merge(page_url_data, URI.parse(rich_media[:image]))
394 provider_name: page_url_data.host,
395 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
397 image: image_url |> MediaProxy.url(),
398 title: rich_media[:title] || "",
399 description: rich_media[:description] || "",
401 opengraph: rich_media
406 def render("card.json", _), do: nil
408 def render("attachment.json", %{attachment: attachment}) do
409 [attachment_url | _] = attachment["url"]
410 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
411 href = attachment_url["href"] |> MediaProxy.url()
415 String.contains?(media_type, "image") -> "image"
416 String.contains?(media_type, "video") -> "video"
417 String.contains?(media_type, "audio") -> "audio"
421 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
424 id: to_string(attachment["id"] || hash_id),
430 description: attachment["name"],
431 pleroma: %{mime_type: media_type}
435 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
436 object = Object.normalize(activity)
438 user = get_user(activity.data["actor"])
439 created_at = Utils.to_masto_date(activity.data["published"])
443 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
444 created_at: created_at,
445 title: object.data["title"] |> HTML.strip_tags(),
446 artist: object.data["artist"] |> HTML.strip_tags(),
447 album: object.data["album"] |> HTML.strip_tags(),
448 length: object.data["length"]
452 def render("listens.json", opts) do
453 safe_render_many(opts.activities, StatusView, "listen.json", opts)
456 def render("context.json", %{activity: activity, activities: activities, user: user}) do
457 %{ancestors: ancestors, descendants: descendants} =
460 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
461 |> Map.put_new(:ancestors, [])
462 |> Map.put_new(:descendants, [])
465 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
466 descendants: render("index.json", for: user, activities: descendants, as: :activity)
470 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
471 object = Object.normalize(activity)
473 with nil <- replied_to_activities[object.data["inReplyTo"]] do
474 # If user didn't participate in the thread
475 Activity.get_in_reply_to_activity(activity)
479 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
480 object = Object.normalize(activity)
482 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
483 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
489 def render_content(%{data: %{"type" => object_type}} = object)
490 when object_type in ["Video", "Event", "Audio"] do
491 with name when not is_nil(name) and name != "" <- object.data["name"] do
492 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
494 _ -> object.data["content"] || ""
498 def render_content(%{data: %{"type" => object_type}} = object)
499 when object_type in ["Article", "Page"] do
500 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
501 url when is_bitstring(url) <- object.data["url"] do
502 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
504 _ -> 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
522 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
524 Enum.reduce(object_tags, [], fn tag, tags ->
525 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
529 def build_tags(_), do: []
534 Arguments: `nil` or list tuple of name and url.
540 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
541 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
544 @spec build_emojis(nil | list(tuple())) :: list(map())
545 def build_emojis(nil), do: []
547 def build_emojis(emojis) do
549 |> Enum.map(fn {name, url} ->
550 name = HTML.strip_tags(name)
557 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
561 defp present?(nil), do: false
562 defp present?(false), do: false
563 defp present?(_), do: true
565 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
566 do: id in pinned_activities