Merge branch 'richmedia-workaround' 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.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 %{
156 id: to_string(activity.id),
157 uri: object.data["id"],
158 url: object.data["id"],
159 account:
160 AccountView.render("show.json", %{
161 user: user,
162 for: opts[:for]
163 }),
164 in_reply_to_id: nil,
165 in_reply_to_account_id: nil,
166 reblog: reblogged,
167 content: reblogged[:content] || "",
168 created_at: created_at,
169 reblogs_count: 0,
170 replies_count: 0,
171 favourites_count: 0,
172 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
173 favourited: present?(favorited),
174 bookmarked: present?(bookmarked),
175 muted: false,
176 pinned: pinned?(activity, user),
177 sensitive: false,
178 spoiler_text: "",
179 visibility: get_visibility(activity),
180 media_attachments: reblogged[:media_attachments] || [],
181 mentions: mentions,
182 tags: reblogged[:tags] || [],
183 application: build_application(object.data["generator"]),
184 language: nil,
185 emojis: [],
186 pleroma: %{
187 local: activity.local
188 }
189 }
190 end
191
192 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
193 object = Object.normalize(activity, fetch: false)
194
195 user = CommonAPI.get_user(activity.data["actor"])
196 user_follower_address = user.follower_address
197
198 like_count = object.data["like_count"] || 0
199 announcement_count = object.data["announcement_count"] || 0
200
201 hashtags = Object.hashtags(object)
202 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
203
204 tags = Object.tags(object)
205
206 tag_mentions =
207 tags
208 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
209 |> Enum.map(fn tag -> tag["href"] end)
210
211 mentions =
212 (object.data["to"] ++ tag_mentions)
213 |> Enum.uniq()
214 |> Enum.map(fn
215 Pleroma.Constants.as_public() -> nil
216 ^user_follower_address -> nil
217 ap_id -> User.get_cached_by_ap_id(ap_id)
218 end)
219 |> Enum.filter(& &1)
220 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
221
222 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
223
224 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
225
226 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
227
228 expires_at =
229 with true <- client_posted_this_activity,
230 %Oban.Job{scheduled_at: scheduled_at} <-
231 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
232 scheduled_at
233 else
234 _ -> nil
235 end
236
237 thread_muted? =
238 cond do
239 is_nil(opts[:for]) -> false
240 is_boolean(activity.thread_muted?) -> activity.thread_muted?
241 true -> CommonAPI.thread_muted?(opts[:for], activity)
242 end
243
244 attachment_data = object.data["attachment"] || []
245 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
246
247 created_at = Utils.to_masto_date(object.data["published"])
248
249 reply_to = get_reply_to(activity, opts)
250
251 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
252
253 content =
254 object
255 |> render_content()
256
257 content_html =
258 content
259 |> HTML.get_cached_scrubbed_html_for_activity(
260 User.html_filter_policy(opts[:for]),
261 activity,
262 "mastoapi:content"
263 )
264
265 content_plaintext =
266 content
267 |> HTML.get_cached_stripped_html_for_activity(
268 activity,
269 "mastoapi:content"
270 )
271
272 summary = object.data["summary"] || ""
273
274 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
275
276 url =
277 if user.local do
278 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
279 else
280 object.data["url"] || object.data["external_url"] || object.data["id"]
281 end
282
283 direct_conversation_id =
284 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
285 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
286 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
287 Activity.direct_conversation_id(activity, for_user)
288 else
289 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
290 participation_id
291
292 _e ->
293 nil
294 end
295
296 emoji_reactions =
297 object.data
298 |> Map.get("reactions", [])
299 |> EmojiReactionController.filter_allowed_users(
300 opts[:for],
301 Map.get(opts, :with_muted, false)
302 )
303 |> Stream.map(fn {emoji, users} ->
304 build_emoji_map(emoji, users, opts[:for])
305 end)
306 |> Enum.to_list()
307
308 # Status muted state (would do 1 request per status unless user mutes are preloaded)
309 muted =
310 thread_muted? ||
311 UserRelationship.exists?(
312 get_in(opts, [:relationships, :user_relationships]),
313 :mute,
314 opts[:for],
315 user,
316 fn for_user, user -> User.mutes?(for_user, user) end
317 )
318
319 %{
320 id: to_string(activity.id),
321 uri: object.data["id"],
322 url: url,
323 account:
324 AccountView.render("show.json", %{
325 user: user,
326 for: opts[:for]
327 }),
328 in_reply_to_id: reply_to && to_string(reply_to.id),
329 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
330 reblog: nil,
331 card: card,
332 content: content_html,
333 text: opts[:with_source] && object.data["source"],
334 created_at: created_at,
335 reblogs_count: announcement_count,
336 replies_count: object.data["repliesCount"] || 0,
337 favourites_count: like_count,
338 reblogged: reblogged?(activity, opts[:for]),
339 favourited: present?(favorited),
340 bookmarked: present?(bookmarked),
341 muted: muted,
342 pinned: pinned?(activity, user),
343 sensitive: sensitive,
344 spoiler_text: summary,
345 visibility: get_visibility(object),
346 media_attachments: attachments,
347 poll: render(PollView, "show.json", object: object, for: opts[:for]),
348 mentions: mentions,
349 tags: build_tags(tags),
350 application: build_application(object.data["generator"]),
351 language: nil,
352 emojis: build_emojis(object.data["emoji"]),
353 pleroma: %{
354 local: activity.local,
355 conversation_id: get_context_id(activity),
356 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
357 content: %{"text/plain" => content_plaintext},
358 spoiler_text: %{"text/plain" => summary},
359 expires_at: expires_at,
360 direct_conversation_id: direct_conversation_id,
361 thread_muted: thread_muted?,
362 emoji_reactions: emoji_reactions,
363 parent_visible: visible_for_user?(reply_to, opts[:for])
364 }
365 }
366 end
367
368 def render("show.json", _) do
369 nil
370 end
371
372 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
373 page_url_data = URI.parse(page_url)
374
375 page_url_data =
376 if is_binary(rich_media["url"]) do
377 URI.merge(page_url_data, URI.parse(rich_media["url"]))
378 else
379 page_url_data
380 end
381
382 page_url = page_url_data |> to_string
383
384 image_url_data =
385 if is_binary(rich_media["image"]) do
386 URI.parse(rich_media["image"])
387 else
388 nil
389 end
390
391 image_url = build_image_url(image_url_data, page_url_data)
392
393 %{
394 type: "link",
395 provider_name: page_url_data.host,
396 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
397 url: page_url,
398 image: image_url |> MediaProxy.url(),
399 title: rich_media["title"] || "",
400 description: rich_media["description"] || "",
401 pleroma: %{
402 opengraph: rich_media
403 }
404 }
405 end
406
407 def render("card.json", _), do: nil
408
409 def render("attachment.json", %{attachment: attachment}) do
410 [attachment_url | _] = attachment["url"]
411 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
412 href = attachment_url["href"] |> MediaProxy.url()
413 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
414
415 type =
416 cond do
417 String.contains?(media_type, "image") -> "image"
418 String.contains?(media_type, "video") -> "video"
419 String.contains?(media_type, "audio") -> "audio"
420 true -> "unknown"
421 end
422
423 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
424
425 %{
426 id: to_string(attachment["id"] || hash_id),
427 url: href,
428 remote_url: href,
429 preview_url: href_preview,
430 text_url: href,
431 type: type,
432 description: attachment["name"],
433 pleroma: %{mime_type: media_type},
434 blurhash: attachment["blurhash"]
435 }
436 end
437
438 def render("context.json", %{activity: activity, activities: activities, user: user}) do
439 %{ancestors: ancestors, descendants: descendants} =
440 activities
441 |> Enum.reverse()
442 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
443 |> Map.put_new(:ancestors, [])
444 |> Map.put_new(:descendants, [])
445
446 %{
447 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
448 descendants: render("index.json", for: user, activities: descendants, as: :activity)
449 }
450 end
451
452 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
453 object = Object.normalize(activity, fetch: false)
454
455 with nil <- replied_to_activities[object.data["inReplyTo"]] do
456 # If user didn't participate in the thread
457 Activity.get_in_reply_to_activity(activity)
458 end
459 end
460
461 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
462 object = Object.normalize(activity, fetch: false)
463
464 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
465 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
466 else
467 nil
468 end
469 end
470
471 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
472 url = object.data["url"] || object.data["id"]
473
474 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
475 end
476
477 def render_content(object), do: object.data["content"] || ""
478
479 @doc """
480 Builds a dictionary tags.
481
482 ## Examples
483
484 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
485 [{"name": "fediverse", "url": "/tag/fediverse"},
486 {"name": "nextcloud", "url": "/tag/nextcloud"}]
487
488 """
489 @spec build_tags(list(any())) :: list(map())
490 def build_tags(object_tags) when is_list(object_tags) do
491 object_tags
492 |> Enum.filter(&is_binary/1)
493 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"})
494 end
495
496 def build_tags(_), do: []
497
498 @doc """
499 Builds list emojis.
500
501 Arguments: `nil` or list tuple of name and url.
502
503 Returns list emojis.
504
505 ## Examples
506
507 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
508 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
509
510 """
511 @spec build_emojis(nil | list(tuple())) :: list(map())
512 def build_emojis(nil), do: []
513
514 def build_emojis(emojis) do
515 emojis
516 |> Enum.map(fn {name, url} ->
517 name = HTML.strip_tags(name)
518
519 url =
520 url
521 |> HTML.strip_tags()
522 |> MediaProxy.url()
523
524 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
525 end)
526 end
527
528 defp present?(nil), do: false
529 defp present?(false), do: false
530 defp present?(_), do: true
531
532 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
533 do: id in pinned_activities
534
535 defp build_emoji_map(emoji, users, current_user) do
536 %{
537 name: emoji,
538 count: length(users),
539 me: !!(current_user && current_user.ap_id in users)
540 }
541 end
542
543 @spec build_application(map() | nil) :: map() | nil
544 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
545 do: %{name: name, website: url}
546
547 defp build_application(_), do: nil
548
549 # Workaround for Elixir issue #10771
550 # Avoid applying URI.merge unless necessary
551 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
552 # when Elixir 1.12 is the minimum supported version
553 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
554 defp build_image_url(
555 %URI{scheme: image_scheme, host: image_host} = image_url_data,
556 %URI{} = _page_url_data
557 )
558 when not is_nil(image_scheme) and not is_nil(image_host) do
559 image_url_data |> to_string
560 end
561
562 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
563 URI.merge(page_url_data, image_url_data) |> to_string
564 end
565
566 defp build_image_url(_, _), do: nil
567 end