Parallelize template rendering
[akkoma] / lib / pleroma / web / mastodon_api / views / status_view.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.MastodonAPI.StatusView do
6 use Pleroma.Web, :view
7
8 require Pleroma.Constants
9
10 alias Pleroma.Activity
11 alias Pleroma.HTML
12 alias Pleroma.Object
13 alias Pleroma.Repo
14 alias Pleroma.User
15 alias Pleroma.Web.CommonAPI
16 alias Pleroma.Web.CommonAPI.Utils
17 alias Pleroma.Web.MastodonAPI.AccountView
18 alias Pleroma.Web.MastodonAPI.StatusView
19 alias Pleroma.Web.MediaProxy
20
21 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1]
22
23 # TODO: Add cached version.
24 defp get_replied_to_activities([]), do: %{}
25
26 defp get_replied_to_activities(activities) do
27 activities
28 |> Enum.map(fn
29 %{data: %{"type" => "Create"}} = activity ->
30 object = Object.normalize(activity)
31 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
32
33 _ ->
34 nil
35 end)
36 |> Enum.filter(& &1)
37 |> Activity.create_by_object_ap_id_with_object()
38 |> Repo.all()
39 |> Enum.reduce(%{}, fn activity, acc ->
40 object = Object.normalize(activity)
41 if object, do: Map.put(acc, object.data["id"], activity), else: acc
42 end)
43 end
44
45 defp get_user(ap_id) do
46 cond do
47 user = User.get_cached_by_ap_id(ap_id) ->
48 user
49
50 user = User.get_by_guessed_nickname(ap_id) ->
51 user
52
53 true ->
54 User.error_user(ap_id)
55 end
56 end
57
58 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
59 do: context_id
60
61 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
62 do: Utils.context_to_conversation_id(context)
63
64 defp get_context_id(_), do: nil
65
66 defp reblogged?(activity, user) do
67 object = Object.normalize(activity) || %{}
68 present?(user && user.ap_id in (object.data["announcements"] || []))
69 end
70
71 def render("index.json", opts) do
72 replied_to_activities = get_replied_to_activities(opts.activities)
73 parallel = unless is_nil(opts[:parallel]), do: opts[:parallel], else: true
74
75 opts.activities
76 |> safe_render_many(
77 StatusView,
78 "status.json",
79 Map.put(opts, :replied_to_activities, replied_to_activities),
80 parallel
81 )
82 end
83
84 def render(
85 "status.json",
86 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
87 ) do
88 user = get_user(activity.data["actor"])
89 created_at = Utils.to_masto_date(activity.data["published"])
90 activity_object = Object.normalize(activity)
91
92 reblogged_activity =
93 Activity.create_by_object_ap_id(activity_object.data["id"])
94 |> Activity.with_preloaded_bookmark(opts[:for])
95 |> Activity.with_set_thread_muted_field(opts[:for])
96 |> Repo.one()
97
98 reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity))
99
100 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
101
102 bookmarked = Activity.get_bookmark(reblogged_activity, opts[:for]) != nil
103
104 mentions =
105 activity.recipients
106 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
107 |> Enum.filter(& &1)
108 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
109
110 %{
111 id: to_string(activity.id),
112 uri: activity_object.data["id"],
113 url: activity_object.data["id"],
114 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
115 in_reply_to_id: nil,
116 in_reply_to_account_id: nil,
117 reblog: reblogged,
118 content: reblogged[:content] || "",
119 created_at: created_at,
120 reblogs_count: 0,
121 replies_count: 0,
122 favourites_count: 0,
123 reblogged: reblogged?(reblogged_activity, opts[:for]),
124 favourited: present?(favorited),
125 bookmarked: present?(bookmarked),
126 muted: false,
127 pinned: pinned?(activity, user),
128 sensitive: false,
129 spoiler_text: "",
130 visibility: "public",
131 media_attachments: reblogged[:media_attachments] || [],
132 mentions: mentions,
133 tags: reblogged[:tags] || [],
134 application: %{
135 name: "Web",
136 website: nil
137 },
138 language: nil,
139 emojis: [],
140 pleroma: %{
141 local: activity.local
142 }
143 }
144 end
145
146 def render("status.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
147 object = Object.normalize(activity)
148
149 user = get_user(activity.data["actor"])
150 user_follower_address = user.follower_address
151
152 like_count = object.data["like_count"] || 0
153 announcement_count = object.data["announcement_count"] || 0
154
155 tags = object.data["tag"] || []
156 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
157
158 tag_mentions =
159 tags
160 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
161 |> Enum.map(fn tag -> tag["href"] end)
162
163 mentions =
164 (object.data["to"] ++ tag_mentions)
165 |> Enum.uniq()
166 |> Enum.map(fn
167 Pleroma.Constants.as_public() -> nil
168 ^user_follower_address -> nil
169 ap_id -> User.get_cached_by_ap_id(ap_id)
170 end)
171 |> Enum.filter(& &1)
172 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
173
174 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
175
176 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
177
178 thread_muted? =
179 case activity.thread_muted? do
180 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
181 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
182 end
183
184 attachment_data = object.data["attachment"] || []
185 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
186
187 created_at = Utils.to_masto_date(object.data["published"])
188
189 reply_to = get_reply_to(activity, opts)
190
191 reply_to_user = reply_to && get_user(reply_to.data["actor"])
192
193 content =
194 object
195 |> render_content()
196
197 content_html =
198 content
199 |> HTML.get_cached_scrubbed_html_for_activity(
200 User.html_filter_policy(opts[:for]),
201 activity,
202 "mastoapi:content"
203 )
204
205 content_plaintext =
206 content
207 |> HTML.get_cached_stripped_html_for_activity(
208 activity,
209 "mastoapi:content"
210 )
211
212 summary = object.data["summary"] || ""
213
214 summary_html =
215 summary
216 |> HTML.get_cached_scrubbed_html_for_activity(
217 User.html_filter_policy(opts[:for]),
218 activity,
219 "mastoapi:summary"
220 )
221
222 summary_plaintext =
223 summary
224 |> HTML.get_cached_stripped_html_for_activity(
225 activity,
226 "mastoapi:summary"
227 )
228
229 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
230
231 url =
232 if user.local do
233 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
234 else
235 object.data["url"] || object.data["external_url"] || object.data["id"]
236 end
237
238 %{
239 id: to_string(activity.id),
240 uri: object.data["id"],
241 url: url,
242 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
243 in_reply_to_id: reply_to && to_string(reply_to.id),
244 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
245 reblog: nil,
246 card: card,
247 content: content_html,
248 created_at: created_at,
249 reblogs_count: announcement_count,
250 replies_count: object.data["repliesCount"] || 0,
251 favourites_count: like_count,
252 reblogged: reblogged?(activity, opts[:for]),
253 favourited: present?(favorited),
254 bookmarked: present?(bookmarked),
255 muted: thread_muted? || User.mutes?(opts[:for], user),
256 pinned: pinned?(activity, user),
257 sensitive: sensitive,
258 spoiler_text: summary_html,
259 visibility: get_visibility(object),
260 media_attachments: attachments,
261 poll: render("poll.json", %{object: object, for: opts[:for]}),
262 mentions: mentions,
263 tags: build_tags(tags),
264 application: %{
265 name: "Web",
266 website: nil
267 },
268 language: nil,
269 emojis: build_emojis(object.data["emoji"]),
270 pleroma: %{
271 local: activity.local,
272 conversation_id: get_context_id(activity),
273 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
274 content: %{"text/plain" => content_plaintext},
275 spoiler_text: %{"text/plain" => summary_plaintext}
276 }
277 }
278 end
279
280 def render("status.json", _) do
281 nil
282 end
283
284 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
285 page_url_data = URI.parse(page_url)
286
287 page_url_data =
288 if rich_media[:url] != nil do
289 URI.merge(page_url_data, URI.parse(rich_media[:url]))
290 else
291 page_url_data
292 end
293
294 page_url = page_url_data |> to_string
295
296 image_url =
297 if rich_media[:image] != nil do
298 URI.merge(page_url_data, URI.parse(rich_media[:image]))
299 |> to_string
300 else
301 nil
302 end
303
304 site_name = rich_media[:site_name] || page_url_data.host
305
306 %{
307 type: "link",
308 provider_name: site_name,
309 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
310 url: page_url,
311 image: image_url |> MediaProxy.url(),
312 title: rich_media[:title] || "",
313 description: rich_media[:description] || "",
314 pleroma: %{
315 opengraph: rich_media
316 }
317 }
318 end
319
320 def render("card.json", _) do
321 nil
322 end
323
324 def render("attachment.json", %{attachment: attachment}) do
325 [attachment_url | _] = attachment["url"]
326 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
327 href = attachment_url["href"] |> MediaProxy.url()
328
329 type =
330 cond do
331 String.contains?(media_type, "image") -> "image"
332 String.contains?(media_type, "video") -> "video"
333 String.contains?(media_type, "audio") -> "audio"
334 true -> "unknown"
335 end
336
337 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
338
339 %{
340 id: to_string(attachment["id"] || hash_id),
341 url: href,
342 remote_url: href,
343 preview_url: href,
344 text_url: href,
345 type: type,
346 description: attachment["name"],
347 pleroma: %{mime_type: media_type}
348 }
349 end
350
351 def render("poll.json", %{object: object} = opts) do
352 {multiple, options} =
353 case object.data do
354 %{"anyOf" => options} when is_list(options) -> {true, options}
355 %{"oneOf" => options} when is_list(options) -> {false, options}
356 _ -> {nil, nil}
357 end
358
359 if options do
360 end_time =
361 (object.data["closed"] || object.data["endTime"])
362 |> NaiveDateTime.from_iso8601!()
363
364 expired =
365 end_time
366 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
367 |> case do
368 :lt -> true
369 _ -> false
370 end
371
372 voted =
373 if opts[:for] do
374 existing_votes =
375 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
376
377 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
378 else
379 false
380 end
381
382 {options, votes_count} =
383 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
384 current_count = option["replies"]["totalItems"] || 0
385
386 {%{
387 title: HTML.strip_tags(name),
388 votes_count: current_count
389 }, current_count + count}
390 end)
391
392 %{
393 # Mastodon uses separate ids for polls, but an object can't have
394 # more than one poll embedded so object id is fine
395 id: to_string(object.id),
396 expires_at: Utils.to_masto_date(end_time),
397 expired: expired,
398 multiple: multiple,
399 votes_count: votes_count,
400 options: options,
401 voted: voted,
402 emojis: build_emojis(object.data["emoji"])
403 }
404 else
405 nil
406 end
407 end
408
409 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
410 object = Object.normalize(activity)
411
412 with nil <- replied_to_activities[object.data["inReplyTo"]] do
413 # If user didn't participate in the thread
414 Activity.get_in_reply_to_activity(activity)
415 end
416 end
417
418 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
419 object = Object.normalize(activity)
420
421 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
422 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
423 else
424 nil
425 end
426 end
427
428 def render_content(%{data: %{"type" => "Video"}} = object) do
429 with name when not is_nil(name) and name != "" <- object.data["name"] do
430 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
431 else
432 _ -> object.data["content"] || ""
433 end
434 end
435
436 def render_content(%{data: %{"type" => object_type}} = object)
437 when object_type in ["Article", "Page"] do
438 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
439 url when is_bitstring(url) <- object.data["url"] do
440 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
441 else
442 _ -> object.data["content"] || ""
443 end
444 end
445
446 def render_content(object), do: object.data["content"] || ""
447
448 @doc """
449 Builds a dictionary tags.
450
451 ## Examples
452
453 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
454 [{"name": "fediverse", "url": "/tag/fediverse"},
455 {"name": "nextcloud", "url": "/tag/nextcloud"}]
456
457 """
458 @spec build_tags(list(any())) :: list(map())
459 def build_tags(object_tags) when is_list(object_tags) do
460 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
461
462 Enum.reduce(object_tags, [], fn tag, tags ->
463 tags ++ [%{name: tag, url: "/tag/#{tag}"}]
464 end)
465 end
466
467 def build_tags(_), do: []
468
469 @doc """
470 Builds list emojis.
471
472 Arguments: `nil` or list tuple of name and url.
473
474 Returns list emojis.
475
476 ## Examples
477
478 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
479 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
480
481 """
482 @spec build_emojis(nil | list(tuple())) :: list(map())
483 def build_emojis(nil), do: []
484
485 def build_emojis(emojis) do
486 emojis
487 |> Enum.map(fn {name, url} ->
488 name = HTML.strip_tags(name)
489
490 url =
491 url
492 |> HTML.strip_tags()
493 |> MediaProxy.url()
494
495 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
496 end)
497 end
498
499 defp present?(nil), do: false
500 defp present?(false), do: false
501 defp present?(_), do: true
502
503 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
504 do: id in pinned_activities
505 end