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