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