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