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
350 def render("attachment.json", %{attachment: attachment}) do
351 [attachment_url | _] = attachment["url"]
352 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
353 href = attachment_url["href"] |> MediaProxy.url()
357 String.contains?(media_type, "image") -> "image"
358 String.contains?(media_type, "video") -> "video"
359 String.contains?(media_type, "audio") -> "audio"
363 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
366 id: to_string(attachment["id"] || hash_id),
372 description: attachment["name"],
373 pleroma: %{mime_type: media_type}
377 def render("poll.json", %{object: object} = opts) do
378 {multiple, options} =
380 %{"anyOf" => options} when is_list(options) -> {true, options}
381 %{"oneOf" => options} when is_list(options) -> {false, options}
386 {end_time, expired} =
387 case object.data["closed"] || object.data["endTime"] do
388 end_time when is_binary(end_time) ->
390 (object.data["closed"] || object.data["endTime"])
391 |> NaiveDateTime.from_iso8601!()
395 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
401 end_time = Utils.to_masto_date(end_time)
412 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
414 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
419 {options, votes_count} =
420 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
421 current_count = option["replies"]["totalItems"] || 0
424 title: HTML.strip_tags(name),
425 votes_count: current_count
426 }, current_count + count}
430 # Mastodon uses separate ids for polls, but an object can't have
431 # more than one poll embedded so object id is fine
432 id: to_string(object.id),
433 expires_at: end_time,
436 votes_count: votes_count,
439 emojis: build_emojis(object.data["emoji"])
446 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
447 object = Object.normalize(activity)
449 with nil <- replied_to_activities[object.data["inReplyTo"]] do
450 # If user didn't participate in the thread
451 Activity.get_in_reply_to_activity(activity)
455 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
456 object = Object.normalize(activity)
458 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
459 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
465 def render_content(%{data: %{"type" => "Video"}} = object) do
466 with name when not is_nil(name) and name != "" <- object.data["name"] do
467 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
469 _ -> object.data["content"] || ""
473 def render_content(%{data: %{"type" => object_type}} = object)
474 when object_type in ["Article", "Page"] do
475 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
476 url when is_bitstring(url) <- object.data["url"] do
477 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
479 _ -> object.data["content"] || ""
483 def render_content(object), do: object.data["content"] || ""
486 Builds a dictionary tags.
490 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
491 [{"name": "fediverse", "url": "/tag/fediverse"},
492 {"name": "nextcloud", "url": "/tag/nextcloud"}]
495 @spec build_tags(list(any())) :: list(map())
496 def build_tags(object_tags) when is_list(object_tags) do
497 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
499 Enum.reduce(object_tags, [], fn tag, tags ->
500 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
504 def build_tags(_), do: []
509 Arguments: `nil` or list tuple of name and url.
515 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
516 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
519 @spec build_emojis(nil | list(tuple())) :: list(map())
520 def build_emojis(nil), do: []
522 def build_emojis(emojis) do
524 |> Enum.map(fn {name, url} ->
525 name = HTML.strip_tags(name)
532 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
536 defp present?(nil), do: false
537 defp present?(false), do: false
538 defp present?(_), do: true
540 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
541 do: id in pinned_activities