Quote posting (#113)
[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 quote = Activity.get_quoted_activity_from_object(object)
333
334 %{
335 id: to_string(activity.id),
336 uri: object.data["id"],
337 url: url,
338 account:
339 AccountView.render("show.json", %{
340 user: user,
341 for: opts[:for]
342 }),
343 in_reply_to_id: reply_to && to_string(reply_to.id),
344 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
345 reblog: nil,
346 card: card,
347 content: content_html,
348 text: opts[:with_source] && object.data["source"],
349 created_at: created_at,
350 reblogs_count: announcement_count,
351 replies_count: object.data["repliesCount"] || 0,
352 favourites_count: like_count,
353 reblogged: reblogged?(activity, opts[:for]),
354 favourited: present?(favorited),
355 bookmarked: present?(bookmarked),
356 muted: muted,
357 pinned: pinned?,
358 sensitive: sensitive,
359 spoiler_text: summary,
360 visibility: get_visibility(object),
361 media_attachments: attachments,
362 poll: render(PollView, "show.json", object: object, for: opts[:for]),
363 mentions: mentions,
364 tags: build_tags(tags),
365 application: build_application(object.data["generator"]),
366 language: nil,
367 emojis: build_emojis(object.data["emoji"]),
368 quote_id: if(quote, do: quote.id, else: nil),
369 quote: maybe_render_quote(quote, opts),
370 pleroma: %{
371 local: activity.local,
372 conversation_id: get_context_id(activity),
373 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
374 content: %{"text/plain" => content_plaintext},
375 spoiler_text: %{"text/plain" => summary},
376 expires_at: expires_at,
377 direct_conversation_id: direct_conversation_id,
378 thread_muted: thread_muted?,
379 emoji_reactions: emoji_reactions,
380 parent_visible: visible_for_user?(reply_to, opts[:for]),
381 pinned_at: pinned_at
382 },
383 akkoma: %{
384 source: object.data["source"]
385 }
386 }
387 end
388
389 def render("show.json", _) do
390 nil
391 end
392
393 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
394 page_url_data = URI.parse(page_url)
395
396 page_url_data =
397 if is_binary(rich_media["url"]) do
398 URI.merge(page_url_data, URI.parse(rich_media["url"]))
399 else
400 page_url_data
401 end
402
403 page_url = page_url_data |> to_string
404
405 image_url_data =
406 if is_binary(rich_media["image"]) do
407 URI.parse(rich_media["image"])
408 else
409 nil
410 end
411
412 image_url = build_image_url(image_url_data, page_url_data)
413
414 %{
415 type: "link",
416 provider_name: page_url_data.host,
417 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
418 url: page_url,
419 image: image_url |> MediaProxy.url(),
420 title: rich_media["title"] || "",
421 description: rich_media["description"] || "",
422 pleroma: %{
423 opengraph: rich_media
424 }
425 }
426 end
427
428 def render("card.json", _), do: nil
429
430 def render("attachment.json", %{attachment: attachment}) do
431 [attachment_url | _] = attachment["url"]
432 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
433 href = attachment_url["href"] |> MediaProxy.url()
434 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
435 meta = render("attachment_meta.json", %{attachment: attachment})
436
437 type =
438 cond do
439 String.contains?(media_type, "image") -> "image"
440 String.contains?(media_type, "video") -> "video"
441 String.contains?(media_type, "audio") -> "audio"
442 true -> "unknown"
443 end
444
445 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
446
447 %{
448 id: to_string(attachment["id"] || hash_id),
449 url: href,
450 remote_url: href,
451 preview_url: href_preview,
452 text_url: href,
453 type: type,
454 description: attachment["name"],
455 pleroma: %{mime_type: media_type},
456 blurhash: attachment["blurhash"]
457 }
458 |> Maps.put_if_present(:meta, meta)
459 end
460
461 def render("attachment_meta.json", %{
462 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
463 })
464 when is_integer(width) and is_integer(height) do
465 %{
466 original: %{
467 width: width,
468 height: height,
469 aspect: width / height
470 }
471 }
472 end
473
474 def render("attachment_meta.json", _), do: nil
475
476 def render("context.json", %{activity: activity, activities: activities, user: user}) do
477 %{ancestors: ancestors, descendants: descendants} =
478 activities
479 |> Enum.reverse()
480 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
481 |> Map.put_new(:ancestors, [])
482 |> Map.put_new(:descendants, [])
483
484 %{
485 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
486 descendants: render("index.json", for: user, activities: descendants, as: :activity)
487 }
488 end
489
490 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
491 object = Object.normalize(activity, fetch: false)
492
493 with nil <- replied_to_activities[object.data["inReplyTo"]] do
494 # If user didn't participate in the thread
495 Activity.get_in_reply_to_activity(activity)
496 end
497 end
498
499 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
500 object = Object.normalize(activity, fetch: false)
501
502 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
503 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
504 else
505 nil
506 end
507 end
508
509 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
510 url = object.data["url"] || object.data["id"]
511
512 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
513 end
514
515 def render_content(object), do: object.data["content"] || ""
516
517 @doc """
518 Builds a dictionary tags.
519
520 ## Examples
521
522 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
523 [{"name": "fediverse", "url": "/tag/fediverse"},
524 {"name": "nextcloud", "url": "/tag/nextcloud"}]
525
526 """
527 @spec build_tags(list(any())) :: list(map())
528 def build_tags(object_tags) when is_list(object_tags) do
529 object_tags
530 |> Enum.filter(&is_binary/1)
531 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
532 end
533
534 def build_tags(_), do: []
535
536 @doc """
537 Builds list emojis.
538
539 Arguments: `nil` or list tuple of name and url.
540
541 Returns list emojis.
542
543 ## Examples
544
545 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
546 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
547
548 """
549 @spec build_emojis(nil | list(tuple())) :: list(map())
550 def build_emojis(nil), do: []
551
552 def build_emojis(emojis) do
553 emojis
554 |> Enum.map(fn {name, url} ->
555 name = HTML.strip_tags(name)
556
557 url =
558 url
559 |> HTML.strip_tags()
560 |> MediaProxy.url()
561
562 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
563 end)
564 end
565
566 defp present?(nil), do: false
567 defp present?(false), do: false
568 defp present?(_), do: true
569
570 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
571 if pinned_at = pinned_objects[object_id] do
572 {true, Utils.to_masto_date(pinned_at)}
573 else
574 {false, nil}
575 end
576 end
577
578 defp build_emoji_map(emoji, users, url, current_user) do
579 %{
580 name: emoji,
581 count: length(users),
582 url: MediaProxy.url(url),
583 me: !!(current_user && current_user.ap_id in users)
584 }
585 end
586
587 @spec build_application(map() | nil) :: map() | nil
588 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
589 do: %{name: name, website: url}
590
591 defp build_application(_), do: nil
592
593 # Workaround for Elixir issue #10771
594 # Avoid applying URI.merge unless necessary
595 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
596 # when Elixir 1.12 is the minimum supported version
597 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
598 defp build_image_url(
599 %URI{scheme: image_scheme, host: image_host} = image_url_data,
600 %URI{} = _page_url_data
601 )
602 when not is_nil(image_scheme) and not is_nil(image_host) do
603 image_url_data |> to_string
604 end
605
606 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
607 URI.merge(page_url_data, image_url_data) |> to_string
608 end
609
610 defp build_image_url(_, _), do: nil
611
612 defp maybe_render_quote(nil, _), do: nil
613
614 defp maybe_render_quote(quote, opts) do
615 if opts[:do_not_recurse] || !visible_for_user?(quote, opts[:for]) do
616 nil
617 else
618 opts =
619 opts
620 |> Map.put(:activity, quote)
621 |> Map.put(:do_not_recurse, true)
622
623 render("show.json", opts)
624 end
625 end
626 end