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
12 alias Pleroma.FollowingRelationship
17 alias Pleroma.UserRelationship
18 alias Pleroma.Web.CommonAPI
19 alias Pleroma.Web.CommonAPI.Utils
20 alias Pleroma.Web.MastodonAPI.AccountView
21 alias Pleroma.Web.MastodonAPI.PollView
22 alias Pleroma.Web.MastodonAPI.StatusView
23 alias Pleroma.Web.MediaProxy
25 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1]
27 # TODO: Add cached version.
28 defp get_replied_to_activities([]), do: %{}
30 defp get_replied_to_activities(activities) do
33 %{data: %{"type" => "Create"}} = activity ->
34 object = Object.normalize(activity)
35 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
41 |> Activity.create_by_object_ap_id_with_object()
43 |> Enum.reduce(%{}, fn activity, acc ->
44 object = Object.normalize(activity)
45 if object, do: Map.put(acc, object.data["id"], activity), else: acc
49 defp get_user(ap_id) do
51 user = User.get_cached_by_ap_id(ap_id) ->
54 user = User.get_by_guessed_nickname(ap_id) ->
58 User.error_user(ap_id)
62 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
65 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
66 do: Utils.context_to_conversation_id(context)
68 defp get_context_id(_), do: nil
70 defp reblogged?(activity, user) do
71 object = Object.normalize(activity) || %{}
72 present?(user && user.ap_id in (object.data["announcements"] || []))
75 def relationships_opts(_reading_user = nil, _actors) do
76 %{user_relationships: [], following_relationships: []}
79 def relationships_opts(reading_user, actors) do
81 UserRelationship.dictionary(
84 [:block, :mute, :notification_mute, :reblog_mute],
85 [:block, :inverse_subscription]
88 following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors)
90 %{user_relationships: user_relationships, following_relationships: following_relationships}
93 def render("index.json", opts) do
94 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
95 activities = Enum.filter(opts.activities, & &1)
96 replied_to_activities = get_replied_to_activities(activities)
100 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
101 |> Enum.map(&Object.normalize(&1).data["id"])
102 |> Activity.create_by_object_ap_id()
103 |> Activity.with_preloaded_object(:left)
104 |> Activity.with_preloaded_bookmark(opts[:for])
105 |> Activity.with_set_thread_muted_field(opts[:for])
108 actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"]))
112 |> Map.put(:replied_to_activities, replied_to_activities)
113 |> Map.put(:parent_activities, parent_activities)
114 |> Map.put(:relationships, relationships_opts(opts[:for], actors))
116 safe_render_many(activities, StatusView, "show.json", opts)
121 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
123 user = get_user(activity.data["actor"])
124 created_at = Utils.to_masto_date(activity.data["published"])
125 activity_object = Object.normalize(activity)
127 reblogged_parent_activity =
128 if opts[:parent_activities] do
129 Activity.Queries.find_by_object_ap_id(
130 opts[:parent_activities],
131 activity_object.data["id"]
134 Activity.create_by_object_ap_id(activity_object.data["id"])
135 |> Activity.with_preloaded_bookmark(opts[:for])
136 |> Activity.with_set_thread_muted_field(opts[:for])
140 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
141 reblogged = render("show.json", reblog_rendering_opts)
143 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
145 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
149 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
151 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
154 id: to_string(activity.id),
155 uri: activity_object.data["id"],
156 url: activity_object.data["id"],
158 AccountView.render("show.json", %{
161 relationships: opts[:relationships]
164 in_reply_to_account_id: nil,
166 content: reblogged[:content] || "",
167 created_at: created_at,
171 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
172 favourited: present?(favorited),
173 bookmarked: present?(bookmarked),
175 pinned: pinned?(activity, user),
178 visibility: get_visibility(activity),
179 media_attachments: reblogged[:media_attachments] || [],
181 tags: reblogged[:tags] || [],
189 local: activity.local
194 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
195 object = Object.normalize(activity)
197 user = get_user(activity.data["actor"])
198 user_follower_address = user.follower_address
200 like_count = object.data["like_count"] || 0
201 announcement_count = object.data["announcement_count"] || 0
203 tags = object.data["tag"] || []
204 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
208 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
209 |> Enum.map(fn tag -> tag["href"] end)
212 (object.data["to"] ++ tag_mentions)
215 Pleroma.Constants.as_public() -> nil
216 ^user_follower_address -> nil
217 ap_id -> User.get_cached_by_ap_id(ap_id)
220 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
222 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
224 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
226 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
229 with true <- client_posted_this_activity,
230 %ActivityExpiration{scheduled_at: scheduled_at} <-
231 ActivityExpiration.get_by_activity_id(activity.id) do
238 case activity.thread_muted? do
239 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
240 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
243 attachment_data = object.data["attachment"] || []
244 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
246 created_at = Utils.to_masto_date(object.data["published"])
248 reply_to = get_reply_to(activity, opts)
250 reply_to_user = reply_to && get_user(reply_to.data["actor"])
258 |> HTML.get_cached_scrubbed_html_for_activity(
259 User.html_filter_policy(opts[:for]),
266 |> HTML.get_cached_stripped_html_for_activity(
271 summary = object.data["summary"] || ""
273 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
277 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
279 object.data["url"] || object.data["external_url"] || object.data["id"]
282 direct_conversation_id =
283 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
284 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
285 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
286 Activity.direct_conversation_id(activity, for_user)
288 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
296 with %{data: %{"reactions" => emoji_reactions}} <- object do
297 Enum.map(emoji_reactions, fn [emoji, users] ->
300 count: length(users),
301 me: !!(opts[:for] && opts[:for].ap_id in users)
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]
328 in_reply_to_id: reply_to && to_string(reply_to.id),
329 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
332 content: content_html,
333 created_at: created_at,
334 reblogs_count: announcement_count,
335 replies_count: object.data["repliesCount"] || 0,
336 favourites_count: like_count,
337 reblogged: reblogged?(activity, opts[:for]),
338 favourited: present?(favorited),
339 bookmarked: present?(bookmarked),
341 pinned: pinned?(activity, user),
342 sensitive: sensitive,
343 spoiler_text: summary,
344 visibility: get_visibility(object),
345 media_attachments: attachments,
346 poll: render(PollView, "show.json", object: object, for: opts[:for]),
348 tags: build_tags(tags),
354 emojis: build_emojis(object.data["emoji"]),
356 local: activity.local,
357 conversation_id: get_context_id(activity),
358 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
359 content: %{"text/plain" => content_plaintext},
360 spoiler_text: %{"text/plain" => summary},
361 expires_at: expires_at,
362 direct_conversation_id: direct_conversation_id,
363 thread_muted: thread_muted?,
364 emoji_reactions: emoji_reactions
369 def render("show.json", _) do
373 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
374 page_url_data = URI.parse(page_url)
377 if rich_media[:url] != nil do
378 URI.merge(page_url_data, URI.parse(rich_media[:url]))
383 page_url = page_url_data |> to_string
386 if rich_media[:image] != nil do
387 URI.merge(page_url_data, URI.parse(rich_media[:image]))
395 provider_name: page_url_data.host,
396 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
398 image: image_url |> MediaProxy.url(),
399 title: rich_media[:title] || "",
400 description: rich_media[:description] || "",
402 opengraph: rich_media
407 def render("card.json", _), do: nil
409 def render("attachment.json", %{attachment: attachment}) do
410 [attachment_url | _] = attachment["url"]
411 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
412 href = attachment_url["href"] |> MediaProxy.url()
416 String.contains?(media_type, "image") -> "image"
417 String.contains?(media_type, "video") -> "video"
418 String.contains?(media_type, "audio") -> "audio"
422 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
425 id: to_string(attachment["id"] || hash_id),
431 description: attachment["name"],
432 pleroma: %{mime_type: media_type}
436 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
437 object = Object.normalize(activity)
439 user = get_user(activity.data["actor"])
440 created_at = Utils.to_masto_date(activity.data["published"])
444 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
445 created_at: created_at,
446 title: object.data["title"] |> HTML.strip_tags(),
447 artist: object.data["artist"] |> HTML.strip_tags(),
448 album: object.data["album"] |> HTML.strip_tags(),
449 length: object.data["length"]
453 def render("listens.json", opts) do
454 safe_render_many(opts.activities, StatusView, "listen.json", opts)
457 def render("context.json", %{activity: activity, activities: activities, user: user}) do
458 %{ancestors: ancestors, descendants: descendants} =
461 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
462 |> Map.put_new(:ancestors, [])
463 |> Map.put_new(:descendants, [])
466 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
467 descendants: render("index.json", for: user, activities: descendants, as: :activity)
471 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
472 object = Object.normalize(activity)
474 with nil <- replied_to_activities[object.data["inReplyTo"]] do
475 # If user didn't participate in the thread
476 Activity.get_in_reply_to_activity(activity)
480 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
481 object = Object.normalize(activity)
483 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
484 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
490 def render_content(%{data: %{"type" => object_type}} = object)
491 when object_type in ["Video", "Event"] do
492 with name when not is_nil(name) and name != "" <- object.data["name"] do
493 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
495 _ -> object.data["content"] || ""
499 def render_content(%{data: %{"type" => object_type}} = object)
500 when object_type in ["Article", "Page"] do
501 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
502 url when is_bitstring(url) <- object.data["url"] do
503 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
505 _ -> object.data["content"] || ""
509 def render_content(object), do: object.data["content"] || ""
512 Builds a dictionary tags.
516 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
517 [{"name": "fediverse", "url": "/tag/fediverse"},
518 {"name": "nextcloud", "url": "/tag/nextcloud"}]
521 @spec build_tags(list(any())) :: list(map())
522 def build_tags(object_tags) when is_list(object_tags) do
523 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
525 Enum.reduce(object_tags, [], fn tag, tags ->
526 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
530 def build_tags(_), do: []
535 Arguments: `nil` or list tuple of name and url.
541 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
542 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
545 @spec build_emojis(nil | list(tuple())) :: list(map())
546 def build_emojis(nil), do: []
548 def build_emojis(emojis) do
550 |> Enum.map(fn {name, url} ->
551 name = HTML.strip_tags(name)
558 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
562 defp present?(nil), do: false
563 defp present?(false), do: false
564 defp present?(_), do: true
566 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
567 do: id in pinned_activities