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