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