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