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