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 reading_user = opts[:for]
77 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
78 activities = Enum.filter(opts.activities, & &1)
79 replied_to_activities = get_replied_to_activities(activities)
83 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
84 |> Enum.map(&Object.normalize(&1).data["id"])
85 |> Activity.create_by_object_ap_id()
86 |> Activity.with_preloaded_object(:left)
87 |> Activity.with_preloaded_bookmark(reading_user)
88 |> Activity.with_set_thread_muted_field(reading_user)
93 Map.has_key?(opts, :relationships) ->
96 is_nil(reading_user) ->
97 UserRelationship.view_relationships_option(nil, [])
100 actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"]))
102 UserRelationship.view_relationships_option(reading_user, actors,
103 source_mutes_only: opts[:skip_relationships]
109 |> Map.put(:replied_to_activities, replied_to_activities)
110 |> Map.put(:parent_activities, parent_activities)
111 |> Map.put(:relationships, relationships_opt)
113 safe_render_many(activities, StatusView, "show.json", opts)
118 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
120 user = get_user(activity.data["actor"])
121 created_at = Utils.to_masto_date(activity.data["published"])
122 activity_object = Object.normalize(activity)
124 reblogged_parent_activity =
125 if opts[:parent_activities] do
126 Activity.Queries.find_by_object_ap_id(
127 opts[:parent_activities],
128 activity_object.data["id"]
131 Activity.create_by_object_ap_id(activity_object.data["id"])
132 |> Activity.with_preloaded_bookmark(opts[:for])
133 |> Activity.with_set_thread_muted_field(opts[:for])
137 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
138 reblogged = render("show.json", reblog_rendering_opts)
140 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
142 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
146 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
148 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
151 id: to_string(activity.id),
152 uri: activity_object.data["id"],
153 url: activity_object.data["id"],
155 AccountView.render("show.json", %{
158 relationships: opts[:relationships],
159 skip_relationships: opts[:skip_relationships]
162 in_reply_to_account_id: nil,
164 content: reblogged[:content] || "",
165 created_at: created_at,
169 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
170 favourited: present?(favorited),
171 bookmarked: present?(bookmarked),
173 pinned: pinned?(activity, user),
176 visibility: get_visibility(activity),
177 media_attachments: reblogged[:media_attachments] || [],
179 tags: reblogged[:tags] || [],
187 local: activity.local
192 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
193 object = Object.normalize(activity)
195 user = get_user(activity.data["actor"])
196 user_follower_address = user.follower_address
198 like_count = object.data["like_count"] || 0
199 announcement_count = object.data["announcement_count"] || 0
201 tags = object.data["tag"] || []
202 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
206 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
207 |> Enum.map(fn tag -> tag["href"] end)
210 (object.data["to"] ++ tag_mentions)
213 Pleroma.Constants.as_public() -> nil
214 ^user_follower_address -> nil
215 ap_id -> User.get_cached_by_ap_id(ap_id)
218 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
220 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
222 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
224 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
227 with true <- client_posted_this_activity,
228 %ActivityExpiration{scheduled_at: scheduled_at} <-
229 ActivityExpiration.get_by_activity_id(activity.id) do
237 is_nil(opts[:for]) -> false
238 is_boolean(activity.thread_muted?) -> activity.thread_muted?
239 true -> CommonAPI.thread_muted?(opts[:for], activity)
242 attachment_data = object.data["attachment"] || []
243 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
245 created_at = Utils.to_masto_date(object.data["published"])
247 reply_to = get_reply_to(activity, opts)
249 reply_to_user = reply_to && get_user(reply_to.data["actor"])
257 |> HTML.get_cached_scrubbed_html_for_activity(
258 User.html_filter_policy(opts[:for]),
265 |> HTML.get_cached_stripped_html_for_activity(
270 summary = object.data["summary"] || ""
272 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
276 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
278 object.data["url"] || object.data["external_url"] || object.data["id"]
281 direct_conversation_id =
282 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
283 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
284 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
285 Activity.direct_conversation_id(activity, for_user)
287 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
295 with %{data: %{"reactions" => emoji_reactions}} <- object do
296 Enum.map(emoji_reactions, fn [emoji, users] ->
299 count: length(users),
300 me: !!(opts[:for] && opts[:for].ap_id in users)
307 # Status muted state (would do 1 request per status unless user mutes are preloaded)
310 UserRelationship.exists?(
311 get_in(opts, [:relationships, :user_relationships]),
315 fn for_user, user -> User.mutes?(for_user, user) end
319 id: to_string(activity.id),
320 uri: object.data["id"],
323 AccountView.render("show.json", %{
326 relationships: opts[:relationships],
327 skip_relationships: opts[:skip_relationships]
329 in_reply_to_id: reply_to && to_string(reply_to.id),
330 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
333 content: content_html,
334 created_at: created_at,
335 reblogs_count: announcement_count,
336 replies_count: object.data["repliesCount"] || 0,
337 favourites_count: like_count,
338 reblogged: reblogged?(activity, opts[:for]),
339 favourited: present?(favorited),
340 bookmarked: present?(bookmarked),
342 pinned: pinned?(activity, user),
343 sensitive: sensitive,
344 spoiler_text: summary,
345 visibility: get_visibility(object),
346 media_attachments: attachments,
347 poll: render(PollView, "show.json", object: object, for: opts[:for]),
349 tags: build_tags(tags),
355 emojis: build_emojis(object.data["emoji"]),
357 local: activity.local,
358 conversation_id: get_context_id(activity),
359 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
360 content: %{"text/plain" => content_plaintext},
361 spoiler_text: %{"text/plain" => summary},
362 expires_at: expires_at,
363 direct_conversation_id: direct_conversation_id,
364 thread_muted: thread_muted?,
365 emoji_reactions: emoji_reactions
370 def render("show.json", _) do
374 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
375 page_url_data = URI.parse(page_url)
378 if rich_media[:url] != nil do
379 URI.merge(page_url_data, URI.parse(rich_media[:url]))
384 page_url = page_url_data |> to_string
387 if rich_media[:image] != nil do
388 URI.merge(page_url_data, URI.parse(rich_media[:image]))
396 provider_name: page_url_data.host,
397 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
399 image: image_url |> MediaProxy.url(),
400 title: rich_media[:title] || "",
401 description: rich_media[:description] || "",
403 opengraph: rich_media
408 def render("card.json", _), do: nil
410 def render("attachment.json", %{attachment: attachment}) do
411 [attachment_url | _] = attachment["url"]
412 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
413 href = attachment_url["href"] |> MediaProxy.url()
417 String.contains?(media_type, "image") -> "image"
418 String.contains?(media_type, "video") -> "video"
419 String.contains?(media_type, "audio") -> "audio"
423 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
426 id: to_string(attachment["id"] || hash_id),
432 description: attachment["name"],
433 pleroma: %{mime_type: media_type}
437 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
438 object = Object.normalize(activity)
440 user = get_user(activity.data["actor"])
441 created_at = Utils.to_masto_date(activity.data["published"])
445 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
446 created_at: created_at,
447 title: object.data["title"] |> HTML.strip_tags(),
448 artist: object.data["artist"] |> HTML.strip_tags(),
449 album: object.data["album"] |> HTML.strip_tags(),
450 length: object.data["length"]
454 def render("listens.json", opts) do
455 safe_render_many(opts.activities, StatusView, "listen.json", opts)
458 def render("context.json", %{activity: activity, activities: activities, user: user}) do
459 %{ancestors: ancestors, descendants: descendants} =
462 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
463 |> Map.put_new(:ancestors, [])
464 |> Map.put_new(:descendants, [])
467 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
468 descendants: render("index.json", for: user, activities: descendants, as: :activity)
472 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
473 object = Object.normalize(activity)
475 with nil <- replied_to_activities[object.data["inReplyTo"]] do
476 # If user didn't participate in the thread
477 Activity.get_in_reply_to_activity(activity)
481 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
482 object = Object.normalize(activity)
484 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
485 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
491 def render_content(%{data: %{"type" => object_type}} = object)
492 when object_type in ["Video", "Event", "Audio"] do
493 with name when not is_nil(name) and name != "" <- object.data["name"] do
494 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
496 _ -> object.data["content"] || ""
500 def render_content(%{data: %{"type" => object_type}} = object)
501 when object_type in ["Article", "Page"] do
502 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
503 url when is_bitstring(url) <- object.data["url"] do
504 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
506 _ -> object.data["content"] || ""
510 def render_content(object), do: object.data["content"] || ""
513 Builds a dictionary tags.
517 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
518 [{"name": "fediverse", "url": "/tag/fediverse"},
519 {"name": "nextcloud", "url": "/tag/nextcloud"}]
522 @spec build_tags(list(any())) :: list(map())
523 def build_tags(object_tags) when is_list(object_tags) do
524 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
526 Enum.reduce(object_tags, [], fn tag, tags ->
527 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
531 def build_tags(_), do: []
536 Arguments: `nil` or list tuple of name and url.
542 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
543 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
546 @spec build_emojis(nil | list(tuple())) :: list(map())
547 def build_emojis(nil), do: []
549 def build_emojis(emojis) do
551 |> Enum.map(fn {name, url} ->
552 name = HTML.strip_tags(name)
559 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
563 defp present?(nil), do: false
564 defp present?(false), do: false
565 defp present?(_), do: true
567 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
568 do: id in pinned_activities