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)
81 Map.put(opts, :replied_to_activities, replied_to_activities)
87 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
89 user = get_user(activity.data["actor"])
90 created_at = Utils.to_masto_date(activity.data["published"])
91 activity_object = Object.normalize(activity)
94 Activity.create_by_object_ap_id(activity_object.data["id"])
95 |> Activity.with_preloaded_bookmark(opts[:for])
96 |> Activity.with_set_thread_muted_field(opts[:for])
99 reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity))
101 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
103 bookmarked = Activity.get_bookmark(reblogged_activity, opts[:for]) != nil
107 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
109 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
112 id: to_string(activity.id),
113 uri: activity_object.data["id"],
114 url: activity_object.data["id"],
115 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
117 in_reply_to_account_id: nil,
119 content: reblogged[:content] || "",
120 created_at: created_at,
124 reblogged: reblogged?(reblogged_activity, opts[:for]),
125 favourited: present?(favorited),
126 bookmarked: present?(bookmarked),
128 pinned: pinned?(activity, user),
131 visibility: "public",
132 media_attachments: reblogged[:media_attachments] || [],
134 tags: reblogged[:tags] || [],
142 local: activity.local
147 def render("status.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
148 object = Object.normalize(activity)
150 user = get_user(activity.data["actor"])
151 user_follower_address = user.follower_address
153 like_count = object.data["like_count"] || 0
154 announcement_count = object.data["announcement_count"] || 0
156 tags = object.data["tag"] || []
157 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
161 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
162 |> Enum.map(fn tag -> tag["href"] end)
165 (object.data["to"] ++ tag_mentions)
168 Pleroma.Constants.as_public() -> nil
169 ^user_follower_address -> nil
170 ap_id -> User.get_cached_by_ap_id(ap_id)
173 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
175 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
177 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
179 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
182 with true <- client_posted_this_activity,
183 expiration when not is_nil(expiration) <-
184 ActivityExpiration.get_by_activity_id(activity.id) do
185 expiration.scheduled_at
189 case activity.thread_muted? do
190 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
191 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
194 attachment_data = object.data["attachment"] || []
195 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
197 created_at = Utils.to_masto_date(object.data["published"])
199 reply_to = get_reply_to(activity, opts)
201 reply_to_user = reply_to && get_user(reply_to.data["actor"])
209 |> HTML.get_cached_scrubbed_html_for_activity(
210 User.html_filter_policy(opts[:for]),
217 |> HTML.get_cached_stripped_html_for_activity(
222 summary = object.data["summary"] || ""
226 |> HTML.get_cached_scrubbed_html_for_activity(
227 User.html_filter_policy(opts[:for]),
234 |> HTML.get_cached_stripped_html_for_activity(
239 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
243 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
245 object.data["url"] || object.data["external_url"] || object.data["id"]
248 direct_conversation_id =
249 with {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
250 {_, %User{} = for_user} <- {:for_user, opts[:for]},
251 %{data: %{"context" => context}} when is_binary(context) <- activity,
252 %Conversation{} = conversation <- Conversation.get_for_ap_id(context),
253 %Participation{id: participation_id} <-
254 Participation.for_user_and_conversation(for_user, conversation) do
262 id: to_string(activity.id),
263 uri: object.data["id"],
265 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
266 in_reply_to_id: reply_to && to_string(reply_to.id),
267 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
270 content: content_html,
271 created_at: created_at,
272 reblogs_count: announcement_count,
273 replies_count: object.data["repliesCount"] || 0,
274 favourites_count: like_count,
275 reblogged: reblogged?(activity, opts[:for]),
276 favourited: present?(favorited),
277 bookmarked: present?(bookmarked),
278 muted: thread_muted? || User.mutes?(opts[:for], user),
279 pinned: pinned?(activity, user),
280 sensitive: sensitive,
281 spoiler_text: summary_html,
282 visibility: get_visibility(object),
283 media_attachments: attachments,
284 poll: render("poll.json", %{object: object, for: opts[:for]}),
286 tags: build_tags(tags),
292 emojis: build_emojis(object.data["emoji"]),
294 local: activity.local,
295 conversation_id: get_context_id(activity),
296 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
297 content: %{"text/plain" => content_plaintext},
298 spoiler_text: %{"text/plain" => summary_plaintext},
299 expires_at: expires_at,
300 direct_conversation_id: direct_conversation_id,
301 thread_muted: thread_muted?
306 def render("status.json", _) do
310 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
311 page_url_data = URI.parse(page_url)
314 if rich_media[:url] != nil do
315 URI.merge(page_url_data, URI.parse(rich_media[:url]))
320 page_url = page_url_data |> to_string
323 if rich_media[:image] != nil do
324 URI.merge(page_url_data, URI.parse(rich_media[:image]))
330 site_name = rich_media[:site_name] || page_url_data.host
334 provider_name: site_name,
335 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
337 image: image_url |> MediaProxy.url(),
338 title: rich_media[:title] || "",
339 description: rich_media[:description] || "",
341 opengraph: rich_media
346 def render("card.json", _), do: nil
348 def render("attachment.json", %{attachment: attachment}) do
349 [attachment_url | _] = attachment["url"]
350 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
351 href = attachment_url["href"] |> MediaProxy.url()
355 String.contains?(media_type, "image") -> "image"
356 String.contains?(media_type, "video") -> "video"
357 String.contains?(media_type, "audio") -> "audio"
361 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
364 id: to_string(attachment["id"] || hash_id),
370 description: attachment["name"],
371 pleroma: %{mime_type: media_type}
375 def render("poll.json", %{object: object} = opts) do
376 {multiple, options} =
378 %{"anyOf" => options} when is_list(options) -> {true, options}
379 %{"oneOf" => options} when is_list(options) -> {false, options}
384 {end_time, expired} =
385 case object.data["closed"] || object.data["endTime"] do
386 end_time when is_binary(end_time) ->
388 (object.data["closed"] || object.data["endTime"])
389 |> NaiveDateTime.from_iso8601!()
393 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
399 end_time = Utils.to_masto_date(end_time)
410 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
412 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
417 {options, votes_count} =
418 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
419 current_count = option["replies"]["totalItems"] || 0
422 title: HTML.strip_tags(name),
423 votes_count: current_count
424 }, current_count + count}
428 # Mastodon uses separate ids for polls, but an object can't have
429 # more than one poll embedded so object id is fine
430 id: to_string(object.id),
431 expires_at: end_time,
434 votes_count: votes_count,
437 emojis: build_emojis(object.data["emoji"])
444 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
445 object = Object.normalize(activity)
447 with nil <- replied_to_activities[object.data["inReplyTo"]] do
448 # If user didn't participate in the thread
449 Activity.get_in_reply_to_activity(activity)
453 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
454 object = Object.normalize(activity)
456 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
457 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
463 def render_content(%{data: %{"type" => "Video"}} = object) do
464 with name when not is_nil(name) and name != "" <- object.data["name"] do
465 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
467 _ -> object.data["content"] || ""
471 def render_content(%{data: %{"type" => object_type}} = object)
472 when object_type in ["Article", "Page"] do
473 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
474 url when is_bitstring(url) <- object.data["url"] do
475 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
477 _ -> object.data["content"] || ""
481 def render_content(object), do: object.data["content"] || ""
484 Builds a dictionary tags.
488 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
489 [{"name": "fediverse", "url": "/tag/fediverse"},
490 {"name": "nextcloud", "url": "/tag/nextcloud"}]
493 @spec build_tags(list(any())) :: list(map())
494 def build_tags(object_tags) when is_list(object_tags) do
495 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
497 Enum.reduce(object_tags, [], fn tag, tags ->
498 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
502 def build_tags(_), do: []
507 Arguments: `nil` or list tuple of name and url.
513 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
514 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
517 @spec build_emojis(nil | list(tuple())) :: list(map())
518 def build_emojis(nil), do: []
520 def build_emojis(emojis) do
522 |> Enum.map(fn {name, url} ->
523 name = HTML.strip_tags(name)
530 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
534 defp present?(nil), do: false
535 defp present?(false), do: false
536 defp present?(_), do: true
538 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
539 do: id in pinned_activities