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])
90 if Map.has_key?(opts, :relationships) do
93 actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"]))
94 UserRelationship.view_relationships_option(opts[:for], actors)
99 |> Map.put(:replied_to_activities, replied_to_activities)
100 |> Map.put(:parent_activities, parent_activities)
101 |> Map.put(:relationships, relationships_opt)
103 safe_render_many(activities, StatusView, "show.json", opts)
108 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
110 user = get_user(activity.data["actor"])
111 created_at = Utils.to_masto_date(activity.data["published"])
112 activity_object = Object.normalize(activity)
114 reblogged_parent_activity =
115 if opts[:parent_activities] do
116 Activity.Queries.find_by_object_ap_id(
117 opts[:parent_activities],
118 activity_object.data["id"]
121 Activity.create_by_object_ap_id(activity_object.data["id"])
122 |> Activity.with_preloaded_bookmark(opts[:for])
123 |> Activity.with_set_thread_muted_field(opts[:for])
127 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
128 reblogged = render("show.json", reblog_rendering_opts)
130 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
132 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
136 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
138 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
141 id: to_string(activity.id),
142 uri: activity_object.data["id"],
143 url: activity_object.data["id"],
145 AccountView.render("show.json", %{
148 relationships: opts[:relationships]
151 in_reply_to_account_id: nil,
153 content: reblogged[:content] || "",
154 created_at: created_at,
158 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
159 favourited: present?(favorited),
160 bookmarked: present?(bookmarked),
162 pinned: pinned?(activity, user),
165 visibility: get_visibility(activity),
166 media_attachments: reblogged[:media_attachments] || [],
168 tags: reblogged[:tags] || [],
176 local: activity.local
181 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
182 object = Object.normalize(activity)
184 user = get_user(activity.data["actor"])
185 user_follower_address = user.follower_address
187 like_count = object.data["like_count"] || 0
188 announcement_count = object.data["announcement_count"] || 0
190 tags = object.data["tag"] || []
191 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
195 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
196 |> Enum.map(fn tag -> tag["href"] end)
199 (object.data["to"] ++ tag_mentions)
202 Pleroma.Constants.as_public() -> nil
203 ^user_follower_address -> nil
204 ap_id -> User.get_cached_by_ap_id(ap_id)
207 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
209 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
211 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
213 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
216 with true <- client_posted_this_activity,
217 %ActivityExpiration{scheduled_at: scheduled_at} <-
218 ActivityExpiration.get_by_activity_id(activity.id) do
225 case activity.thread_muted? do
226 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
227 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
230 attachment_data = object.data["attachment"] || []
231 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
233 created_at = Utils.to_masto_date(object.data["published"])
235 reply_to = get_reply_to(activity, opts)
237 reply_to_user = reply_to && get_user(reply_to.data["actor"])
245 |> HTML.get_cached_scrubbed_html_for_activity(
246 User.html_filter_policy(opts[:for]),
253 |> HTML.get_cached_stripped_html_for_activity(
258 summary = object.data["summary"] || ""
260 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
264 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
266 object.data["url"] || object.data["external_url"] || object.data["id"]
269 direct_conversation_id =
270 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
271 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
272 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
273 Activity.direct_conversation_id(activity, for_user)
275 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
283 with %{data: %{"reactions" => emoji_reactions}} <- object do
284 Enum.map(emoji_reactions, fn [emoji, users] ->
287 count: length(users),
288 me: !!(opts[:for] && opts[:for].ap_id in users)
297 UserRelationship.exists?(
298 get_in(opts, [:relationships, :user_relationships]),
302 fn for_user, user -> User.mutes?(for_user, user) end
306 id: to_string(activity.id),
307 uri: object.data["id"],
310 AccountView.render("show.json", %{
313 relationships: opts[:relationships]
315 in_reply_to_id: reply_to && to_string(reply_to.id),
316 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
319 content: content_html,
320 created_at: created_at,
321 reblogs_count: announcement_count,
322 replies_count: object.data["repliesCount"] || 0,
323 favourites_count: like_count,
324 reblogged: reblogged?(activity, opts[:for]),
325 favourited: present?(favorited),
326 bookmarked: present?(bookmarked),
328 pinned: pinned?(activity, user),
329 sensitive: sensitive,
330 spoiler_text: summary,
331 visibility: get_visibility(object),
332 media_attachments: attachments,
333 poll: render(PollView, "show.json", object: object, for: opts[:for]),
335 tags: build_tags(tags),
341 emojis: build_emojis(object.data["emoji"]),
343 local: activity.local,
344 conversation_id: get_context_id(activity),
345 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
346 content: %{"text/plain" => content_plaintext},
347 spoiler_text: %{"text/plain" => summary},
348 expires_at: expires_at,
349 direct_conversation_id: direct_conversation_id,
350 thread_muted: thread_muted?,
351 emoji_reactions: emoji_reactions
356 def render("show.json", _) do
360 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
361 page_url_data = URI.parse(page_url)
364 if rich_media[:url] != nil do
365 URI.merge(page_url_data, URI.parse(rich_media[:url]))
370 page_url = page_url_data |> to_string
373 if rich_media[:image] != nil do
374 URI.merge(page_url_data, URI.parse(rich_media[:image]))
382 provider_name: page_url_data.host,
383 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
385 image: image_url |> MediaProxy.url(),
386 title: rich_media[:title] || "",
387 description: rich_media[:description] || "",
389 opengraph: rich_media
394 def render("card.json", _), do: nil
396 def render("attachment.json", %{attachment: attachment}) do
397 [attachment_url | _] = attachment["url"]
398 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
399 href = attachment_url["href"] |> MediaProxy.url()
403 String.contains?(media_type, "image") -> "image"
404 String.contains?(media_type, "video") -> "video"
405 String.contains?(media_type, "audio") -> "audio"
409 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
412 id: to_string(attachment["id"] || hash_id),
418 description: attachment["name"],
419 pleroma: %{mime_type: media_type}
423 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
424 object = Object.normalize(activity)
426 user = get_user(activity.data["actor"])
427 created_at = Utils.to_masto_date(activity.data["published"])
431 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
432 created_at: created_at,
433 title: object.data["title"] |> HTML.strip_tags(),
434 artist: object.data["artist"] |> HTML.strip_tags(),
435 album: object.data["album"] |> HTML.strip_tags(),
436 length: object.data["length"]
440 def render("listens.json", opts) do
441 safe_render_many(opts.activities, StatusView, "listen.json", opts)
444 def render("context.json", %{activity: activity, activities: activities, user: user}) do
445 %{ancestors: ancestors, descendants: descendants} =
448 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
449 |> Map.put_new(:ancestors, [])
450 |> Map.put_new(:descendants, [])
453 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
454 descendants: render("index.json", for: user, activities: descendants, as: :activity)
458 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
459 object = Object.normalize(activity)
461 with nil <- replied_to_activities[object.data["inReplyTo"]] do
462 # If user didn't participate in the thread
463 Activity.get_in_reply_to_activity(activity)
467 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
468 object = Object.normalize(activity)
470 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
471 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
477 def render_content(%{data: %{"type" => object_type}} = object)
478 when object_type in ["Video", "Event"] do
479 with name when not is_nil(name) and name != "" <- object.data["name"] do
480 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
482 _ -> object.data["content"] || ""
486 def render_content(%{data: %{"type" => object_type}} = object)
487 when object_type in ["Article", "Page"] do
488 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
489 url when is_bitstring(url) <- object.data["url"] do
490 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
492 _ -> object.data["content"] || ""
496 def render_content(object), do: object.data["content"] || ""
499 Builds a dictionary tags.
503 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
504 [{"name": "fediverse", "url": "/tag/fediverse"},
505 {"name": "nextcloud", "url": "/tag/nextcloud"}]
508 @spec build_tags(list(any())) :: list(map())
509 def build_tags(object_tags) when is_list(object_tags) do
510 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
512 Enum.reduce(object_tags, [], fn tag, tags ->
513 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
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