1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 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.Conversation
13 alias Pleroma.Conversation.Participation
18 alias Pleroma.Web.CommonAPI
19 alias Pleroma.Web.CommonAPI.Utils
20 alias Pleroma.Web.MastodonAPI.AccountView
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 replied_to_activities = get_replied_to_activities(opts.activities)
76 opts = Map.put(opts, :replied_to_activities, replied_to_activities)
78 safe_render_many(opts.activities, StatusView, "show.json", opts)
83 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
85 user = get_user(activity.data["actor"])
86 created_at = Utils.to_masto_date(activity.data["published"])
87 activity_object = Object.normalize(activity)
90 Activity.create_by_object_ap_id(activity_object.data["id"])
91 |> Activity.with_preloaded_bookmark(opts[:for])
92 |> Activity.with_set_thread_muted_field(opts[:for])
95 reblogged = render("show.json", Map.put(opts, :activity, reblogged_activity))
97 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
99 bookmarked = Activity.get_bookmark(reblogged_activity, opts[:for]) != nil
103 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
105 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
108 id: to_string(activity.id),
109 uri: activity_object.data["id"],
110 url: activity_object.data["id"],
111 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
113 in_reply_to_account_id: nil,
115 content: reblogged[:content] || "",
116 created_at: created_at,
120 reblogged: reblogged?(reblogged_activity, opts[:for]),
121 favourited: present?(favorited),
122 bookmarked: present?(bookmarked),
124 pinned: pinned?(activity, user),
127 visibility: "public",
128 media_attachments: reblogged[:media_attachments] || [],
130 tags: reblogged[:tags] || [],
138 local: activity.local
143 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
144 object = Object.normalize(activity)
146 user = get_user(activity.data["actor"])
147 user_follower_address = user.follower_address
149 like_count = object.data["like_count"] || 0
150 announcement_count = object.data["announcement_count"] || 0
152 tags = object.data["tag"] || []
153 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
157 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
158 |> Enum.map(fn tag -> tag["href"] end)
161 (object.data["to"] ++ tag_mentions)
164 Pleroma.Constants.as_public() -> nil
165 ^user_follower_address -> nil
166 ap_id -> User.get_cached_by_ap_id(ap_id)
169 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
171 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
173 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
175 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
178 with true <- client_posted_this_activity,
179 expiration when not is_nil(expiration) <-
180 ActivityExpiration.get_by_activity_id(activity.id) do
181 expiration.scheduled_at
185 case activity.thread_muted? do
186 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
187 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
190 attachment_data = object.data["attachment"] || []
191 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
193 created_at = Utils.to_masto_date(object.data["published"])
195 reply_to = get_reply_to(activity, opts)
197 reply_to_user = reply_to && get_user(reply_to.data["actor"])
205 |> HTML.get_cached_scrubbed_html_for_activity(
206 User.html_filter_policy(opts[:for]),
213 |> HTML.get_cached_stripped_html_for_activity(
218 summary = object.data["summary"] || ""
222 |> HTML.get_cached_scrubbed_html_for_activity(
223 User.html_filter_policy(opts[:for]),
230 |> HTML.get_cached_stripped_html_for_activity(
235 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
239 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
241 object.data["url"] || object.data["external_url"] || object.data["id"]
244 direct_conversation_id =
245 with {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
246 {_, %User{} = for_user} <- {:for_user, opts[:for]},
247 %{data: %{"context" => context}} when is_binary(context) <- activity,
248 %Conversation{} = conversation <- Conversation.get_for_ap_id(context),
249 %Participation{id: participation_id} <-
250 Participation.for_user_and_conversation(for_user, conversation) do
258 id: to_string(activity.id),
259 uri: object.data["id"],
261 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
262 in_reply_to_id: reply_to && to_string(reply_to.id),
263 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
266 content: content_html,
267 created_at: created_at,
268 reblogs_count: announcement_count,
269 replies_count: object.data["repliesCount"] || 0,
270 favourites_count: like_count,
271 reblogged: reblogged?(activity, opts[:for]),
272 favourited: present?(favorited),
273 bookmarked: present?(bookmarked),
274 muted: thread_muted? || User.mutes?(opts[:for], user),
275 pinned: pinned?(activity, user),
276 sensitive: sensitive,
277 spoiler_text: summary_html,
278 visibility: get_visibility(object),
279 media_attachments: attachments,
280 poll: render("poll.json", %{object: object, for: opts[:for]}),
282 tags: build_tags(tags),
288 emojis: build_emojis(object.data["emoji"]),
290 local: activity.local,
291 conversation_id: get_context_id(activity),
292 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
293 content: %{"text/plain" => content_plaintext},
294 spoiler_text: %{"text/plain" => summary_plaintext},
295 expires_at: expires_at,
296 direct_conversation_id: direct_conversation_id,
297 thread_muted: thread_muted?
302 def render("show.json", _) do
306 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
307 page_url_data = URI.parse(page_url)
310 if rich_media[:url] != nil do
311 URI.merge(page_url_data, URI.parse(rich_media[:url]))
316 page_url = page_url_data |> to_string
319 if rich_media[:image] != nil do
320 URI.merge(page_url_data, URI.parse(rich_media[:image]))
326 site_name = rich_media[:site_name] || page_url_data.host
330 provider_name: site_name,
331 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
333 image: image_url |> MediaProxy.url(),
334 title: rich_media[:title] || "",
335 description: rich_media[:description] || "",
337 opengraph: rich_media
342 def render("card.json", _), do: nil
344 def render("attachment.json", %{attachment: attachment}) do
345 [attachment_url | _] = attachment["url"]
346 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
347 href = attachment_url["href"] |> MediaProxy.url()
351 String.contains?(media_type, "image") -> "image"
352 String.contains?(media_type, "video") -> "video"
353 String.contains?(media_type, "audio") -> "audio"
357 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
360 id: to_string(attachment["id"] || hash_id),
366 description: attachment["name"],
367 pleroma: %{mime_type: media_type}
371 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
372 object = Object.normalize(activity)
374 user = get_user(activity.data["actor"])
375 created_at = Utils.to_masto_date(activity.data["published"])
379 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
380 created_at: created_at,
381 title: object.data["title"] |> HTML.strip_tags(),
382 artist: object.data["artist"] |> HTML.strip_tags(),
383 album: object.data["album"] |> HTML.strip_tags(),
384 length: object.data["length"]
388 def render("listens.json", opts) do
389 safe_render_many(opts.activities, StatusView, "listen.json", opts)
392 def render("poll.json", %{object: object} = opts) do
393 {multiple, options} =
395 %{"anyOf" => options} when is_list(options) -> {true, options}
396 %{"oneOf" => options} when is_list(options) -> {false, options}
401 {end_time, expired} =
402 case object.data["closed"] || object.data["endTime"] do
403 end_time when is_binary(end_time) ->
405 (object.data["closed"] || object.data["endTime"])
406 |> NaiveDateTime.from_iso8601!()
410 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
416 end_time = Utils.to_masto_date(end_time)
427 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
429 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
434 {options, votes_count} =
435 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
436 current_count = option["replies"]["totalItems"] || 0
439 title: HTML.strip_tags(name),
440 votes_count: current_count
441 }, current_count + count}
445 # Mastodon uses separate ids for polls, but an object can't have
446 # more than one poll embedded so object id is fine
447 id: to_string(object.id),
448 expires_at: end_time,
451 votes_count: votes_count,
454 emojis: build_emojis(object.data["emoji"])
461 def render("context.json", %{activity: activity, activities: activities, user: user}) do
462 %{ancestors: ancestors, descendants: descendants} =
465 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
466 |> Map.put_new(:ancestors, [])
467 |> Map.put_new(:descendants, [])
470 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
471 descendants: render("index.json", for: user, activities: descendants, as: :activity)
475 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
476 object = Object.normalize(activity)
478 with nil <- replied_to_activities[object.data["inReplyTo"]] do
479 # If user didn't participate in the thread
480 Activity.get_in_reply_to_activity(activity)
484 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
485 object = Object.normalize(activity)
487 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
488 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
494 def render_content(%{data: %{"type" => "Video"}} = object) do
495 with name when not is_nil(name) and name != "" <- object.data["name"] do
496 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
498 _ -> object.data["content"] || ""
502 def render_content(%{data: %{"type" => object_type}} = object)
503 when object_type in ["Article", "Page"] do
504 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
505 url when is_bitstring(url) <- object.data["url"] do
506 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
508 _ -> object.data["content"] || ""
512 def render_content(object), do: object.data["content"] || ""
515 Builds a dictionary tags.
519 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
520 [{"name": "fediverse", "url": "/tag/fediverse"},
521 {"name": "nextcloud", "url": "/tag/nextcloud"}]
524 @spec build_tags(list(any())) :: list(map())
525 def build_tags(object_tags) when is_list(object_tags) do
526 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
528 Enum.reduce(object_tags, [], fn tag, tags ->
529 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
533 def build_tags(_), do: []
538 Arguments: `nil` or list tuple of name and url.
544 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
545 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
548 @spec build_emojis(nil | list(tuple())) :: list(map())
549 def build_emojis(nil), do: []
551 def build_emojis(emojis) do
553 |> Enum.map(fn {name, url} ->
554 name = HTML.strip_tags(name)
561 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
565 defp present?(nil), do: false
566 defp present?(false), do: false
567 defp present?(_), do: true
569 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
570 do: id in pinned_activities